From 581eb8ad2d38adf762d37267dddd7141082b6836 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Tue, 13 Oct 2026 14:58:16 +0000 Subject: [PATCH 1/4] ci: shard integration tests and rebalance CLI e2e shards (#7127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Run integration tests` ran every package's integration suite in one job, with the stack suite as the long pole. This turns it into a matrix: the stack suite in three Vitest shards, the CLI suite in two, and the API suite plus the stack namespace e2e tests in their own entry. The `Run unit and integration tests` summary gate still aggregates the matrix result. CLI e2e files run serially, so a shard can be no shorter than its longest file, and `start.lifecycle.e2e.test.ts` alone set that floor. Its two health-check scenarios move to `start.health.e2e.test.ts`, the e2e timings are refreshed from a current run, and CLI e2e runs in five shards instead of four. ## Effect on the Test workflow Compared with the same workflow on `develop`, excluding time spent waiting for a runner: - The slowest integration job is about 55% shorter, so integration tests are no longer on the critical path. - The slowest CLI e2e shard spends about 15% less time in tests. One shard still runs about 25% over the others, so the timing-based split is not yet even. - The workflow as a whole finishes about 20% sooner. It is now bounded by the CLI and stack e2e jobs, which end within a few seconds of each other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/test.yml | 26 ++- .../commands/start/start.health.e2e.test.ts | 161 ++++++++++++++++++ .../start/start.lifecycle.e2e.test.ts | 120 ------------- apps/cli/tests/e2e-timings.json | 108 ++++++------ 4 files changed, 237 insertions(+), 178 deletions(-) create mode 100644 apps/cli/src/commands/start/start.health.e2e.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1888b65913..b823b5702b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -111,8 +111,18 @@ jobs: (github.event_name == 'merge_group' || inputs.force || github.event.pull_request.draft == false) - name: Run integration tests + name: Run integration tests (${{ matrix.name }}) runs-on: blacksmith-8vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + include: + - { package: "@supabase/stack", shard: 1/3, name: stack 1/3 } + - { package: "@supabase/stack", shard: 2/3, name: stack 2/3 } + - { package: "@supabase/stack", shard: 3/3, name: stack 3/3 } + - { package: supabase, shard: 1/2, name: cli 1/2 } + - { package: supabase, shard: 2/2, name: cli 2/2 } + - { package: "@supabase/api", name: api, stack-e2e: true } steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -126,11 +136,14 @@ jobs: go-cache: "false" - name: Run integration tests - run: pnpm run test:integration + run: >- + pnpm --filter "${{ matrix.package }}" test:integration:run + ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} # Not covered by test:integration above (a different vitest project); runs here so the # registry cross-process and publication-crash scenarios run on Linux too. - name: Run stack namespace e2e tests + if: matrix.stack-e2e working-directory: packages/stack run: >- pnpm test:e2e:run @@ -265,10 +278,11 @@ jobs: fail-fast: false matrix: include: - - { suite: cli, shard: 1, shards: 4, name: cli 1/4 } - - { suite: cli, shard: 2, shards: 4, name: cli 2/4 } - - { suite: cli, shard: 3, shards: 4, name: cli 3/4 } - - { suite: cli, shard: 4, shards: 4, name: cli 4/4 } + - { suite: cli, shard: 1, shards: 5, name: cli 1/5 } + - { suite: cli, shard: 2, shards: 5, name: cli 2/5 } + - { suite: cli, shard: 3, shards: 5, name: cli 3/5 } + - { suite: cli, shard: 4, shards: 5, name: cli 4/5 } + - { suite: cli, shard: 5, shards: 5, name: cli 5/5 } - { suite: cli-e2e, name: cli-e2e } steps: - name: Checkout diff --git a/apps/cli/src/commands/start/start.health.e2e.test.ts b/apps/cli/src/commands/start/start.health.e2e.test.ts new file mode 100644 index 0000000000..28463111ed --- /dev/null +++ b/apps/cli/src/commands/start/start.health.e2e.test.ts @@ -0,0 +1,161 @@ +import { BunServices } from "@effect/platform-bun"; +import { Data, Effect, FileSystem, Path } from "effect"; +import { describe, expect, it } from "@effect/vitest"; + +import { + overrideStackPorts, + requireCliSuccess, + runSupabaseEffect, + runDockerEffect, +} from "../../../tests/helpers/cli.ts"; +import { sanitizeProjectId, serviceContainerName } from "../../command-internal/docker-ids.ts"; +import { getRegistryImageUrl } from "../../command-internal/docker-registry.ts"; +import { SERVICE_CATALOG } from "../../command-internal/service-catalog.ts"; +import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; + +class StartE2eSetupError extends Data.TaggedError("StartE2eSetupError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} +const overridePorts = (dir: string) => + Effect.tryPromise({ + try: () => overrideStackPorts(dir), + catch: (cause) => new StartE2eSetupError({ message: "failed to override stack ports", cause }), + }); +const makeProject = Effect.fnUntraced(function* (prefix: string) { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix }); + // Best-effort: a leaked local stack would otherwise pollute the CI runner for later jobs. + yield* Effect.addFinalizer(() => + runSupabaseEffect(["stop", "--no-backup"], { cwd: dir }).pipe(Effect.ignore), + ); + return dir; +}); + +const START_TIMEOUT_MS = 280_000; +const SHORT_E2E_TIMEOUT_MS = 30_000; +const LIFECYCLE_OVERHEAD_MS = 90_000; +const CLEANUP_TIMEOUT_MS = 120_000; + +// `start`'s correctness genuinely depends on a real Docker daemon — real label filtering and +// container lifecycle, not just CLI exit codes. See `stop.e2e.test.ts` and AGENTS.md's "e2e +// tests" section for the runner-gating convention. +describe("supabase start health (e2e)", () => { + // The health watch inspects and dumps logs by container name against a real daemon and derives + // recovery advice from real log bytes — not observable through in-process mocks. Reproduces + // supabase/cli#5952: a locally cached image that cannot be executed. + it.live( + "names the container and its image when a cached image cannot be executed", + () => + Effect.gen(function* () { + const projectDir = yield* makeProject("sb-start-e2e-exec-"); + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const projectId = sanitizeProjectId(path.basename(projectDir)); + const mailpitContainer = serviceContainerName("inbucket", projectId); + // The exact tag `start` resolves for Mailpit, so its already-cached check finds this + // broken build without reaching a registry. + const mailpitImage = yield* getRegistryImageUrl( + dockerfileServiceImage("mailpit", yield* slimImagesEnabled), + ); + + const init = yield* runSupabaseEffect(["init"], { + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + yield* overridePorts(projectDir); + + // A `scratch` image whose entrypoint is not an executable binary — the + // kernel refuses it with exactly the "exec format error" this diagnoses. + const buildDir = path.join(projectDir, "broken-image"); + yield* fs.makeDirectory(buildDir, { recursive: true }); + yield* fs.writeFileString(path.join(buildDir, "mailpit"), "not an executable\n", { + mode: 0o755, + }); + yield* fs.writeFileString( + path.join(buildDir, "Dockerfile"), + 'FROM scratch\nCOPY mailpit /mailpit\nENTRYPOINT ["/mailpit"]\n', + ); + yield* Effect.acquireRelease( + runDockerEffect(["build", "-q", "-t", mailpitImage, buildDir]).pipe( + Effect.as(mailpitImage), + ), + // Never leave a poisoned tag behind for later jobs on this runner. + (image) => runDockerEffect(["image", "rm", "-f", image]).pipe(Effect.ignore), + ); + + // Everything except Postgres and Mailpit is excluded: this scenario only + // needs one container that cannot start. + const excludeArgs = SERVICE_CATALOG.flatMap((entry) => + entry.excludeKey === undefined || entry.excludeKey === "mailpit" + ? [] + : ["--exclude", entry.excludeKey], + ); + const start = yield* runSupabaseEffect(["start", ...excludeArgs], { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).not.toBe(0); + // The container's established name is "inbucket", not the id `docker create` returns. + expect(start.stderr).toContain(`${mailpitContainer} container logs:`); + expect(start.stderr).toContain(`${mailpitContainer} container is not ready`); + expect(start.stderr).toContain(`${mailpitContainer}'s image ${mailpitImage}`); + expect(start.stderr).toContain(`image rm -f ${mailpitImage}`); + }).pipe(Effect.provide(BunServices.layer)), + START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS + CLEANUP_TIMEOUT_MS, + ); + + // Only Postgres, Logflare, and Vector run. Every other scenario here excludes Logflare and Vector, + // so this is the one that exercises the Vector image's entrypoint end to end. + it.live( + "starts Vector against a real Logflare and reaches healthy", + () => + Effect.gen(function* () { + const projectDir = yield* makeProject("sb-start-e2e-vector-"); + const path = yield* Path.Path; + const projectId = sanitizeProjectId(path.basename(projectDir)); + const vectorContainer = serviceContainerName("vector", projectId); + + const init = yield* runSupabaseEffect(["init"], { + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + yield* overridePorts(projectDir); + + const excludeArgs = SERVICE_CATALOG.flatMap((entry) => + entry.excludeKey === undefined || + entry.excludeKey === "logflare" || + entry.excludeKey === "vector" + ? [] + : ["--exclude", entry.excludeKey], + ); + const start = yield* runSupabaseEffect(["start", ...excludeArgs], { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + + if (start.exitCode !== 0) { + const logs = yield* runDockerEffect(["logs", vectorContainer]).pipe( + Effect.map(({ stdout, stderr }) => `${stdout}${stderr}`.trim() || ""), + Effect.catch((error) => Effect.succeed(``)), + ); + throw new Error( + `start failed (exit ${start.exitCode})\nstdout:\n${start.stdout}\nstderr:\n${start.stderr}\n${vectorContainer} logs:\n${logs}`, + ); + } + + const health = yield* runDockerEffect([ + "inspect", + vectorContainer, + "--format", + "{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}", + ]); + expect(health.stdout.trim()).toBe("healthy"); + }).pipe(Effect.provide(BunServices.layer)), + START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS + CLEANUP_TIMEOUT_MS, + ); +}); diff --git a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts index 59b3468509..439d4b2776 100644 --- a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts +++ b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts @@ -14,10 +14,7 @@ import { serviceContainerName, localDbContainerId, } from "../../command-internal/docker-ids.ts"; -import { getRegistryImageUrl } from "../../command-internal/docker-registry.ts"; import { SERVICE_CATALOG } from "../../command-internal/service-catalog.ts"; -import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; -import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; class StartE2eSetupError extends Data.TaggedError("StartE2eSetupError")<{ readonly message: string; @@ -272,121 +269,4 @@ describe("supabase start (e2e)", () => { }).pipe(Effect.provide(BunServices.layer)), START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS + CLEANUP_TIMEOUT_MS, ); - - // The health watch inspects and dumps logs by container name against a real daemon and derives - // recovery advice from real log bytes — not observable through in-process mocks. Reproduces - // supabase/cli#5952: a locally cached image that cannot be executed. - it.live( - "names the container and its image when a cached image cannot be executed", - () => - Effect.gen(function* () { - const projectDir = yield* makeProject("sb-start-e2e-exec-"); - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const projectId = sanitizeProjectId(path.basename(projectDir)); - const mailpitContainer = serviceContainerName("inbucket", projectId); - // The exact tag `start` resolves for Mailpit, so its already-cached check finds this - // broken build without reaching a registry. - const mailpitImage = yield* getRegistryImageUrl( - dockerfileServiceImage("mailpit", yield* slimImagesEnabled), - ); - - const init = yield* runSupabaseEffect(["init"], { - cwd: projectDir, - exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, - }); - requireCliSuccess(init, "init setup"); - yield* overridePorts(projectDir); - - // A `scratch` image whose entrypoint is not an executable binary — the - // kernel refuses it with exactly the "exec format error" this diagnoses. - const buildDir = path.join(projectDir, "broken-image"); - yield* fs.makeDirectory(buildDir, { recursive: true }); - yield* fs.writeFileString(path.join(buildDir, "mailpit"), "not an executable\n", { - mode: 0o755, - }); - yield* fs.writeFileString( - path.join(buildDir, "Dockerfile"), - 'FROM scratch\nCOPY mailpit /mailpit\nENTRYPOINT ["/mailpit"]\n', - ); - yield* Effect.acquireRelease( - runDockerEffect(["build", "-q", "-t", mailpitImage, buildDir]).pipe( - Effect.as(mailpitImage), - ), - // Never leave a poisoned tag behind for later jobs on this runner. - (image) => runDockerEffect(["image", "rm", "-f", image]).pipe(Effect.ignore), - ); - - // Everything except Postgres and Mailpit is excluded: this scenario only - // needs one container that cannot start. - const excludeArgs = SERVICE_CATALOG.flatMap((entry) => - entry.excludeKey === undefined || entry.excludeKey === "mailpit" - ? [] - : ["--exclude", entry.excludeKey], - ); - const start = yield* runSupabaseEffect(["start", ...excludeArgs], { - cwd: projectDir, - exitTimeoutMs: START_TIMEOUT_MS, - }); - - expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).not.toBe(0); - // The container's established name is "inbucket", not the id `docker create` returns. - expect(start.stderr).toContain(`${mailpitContainer} container logs:`); - expect(start.stderr).toContain(`${mailpitContainer} container is not ready`); - expect(start.stderr).toContain(`${mailpitContainer}'s image ${mailpitImage}`); - expect(start.stderr).toContain(`image rm -f ${mailpitImage}`); - }).pipe(Effect.provide(BunServices.layer)), - START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS + CLEANUP_TIMEOUT_MS, - ); - - // Only Postgres, Logflare, and Vector run. Every other scenario here excludes Logflare and Vector, - // so this is the one that exercises the Vector image's entrypoint end to end. - it.live( - "starts Vector against a real Logflare and reaches healthy", - () => - Effect.gen(function* () { - const projectDir = yield* makeProject("sb-start-e2e-vector-"); - const path = yield* Path.Path; - const projectId = sanitizeProjectId(path.basename(projectDir)); - const vectorContainer = serviceContainerName("vector", projectId); - - const init = yield* runSupabaseEffect(["init"], { - cwd: projectDir, - exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, - }); - requireCliSuccess(init, "init setup"); - yield* overridePorts(projectDir); - - const excludeArgs = SERVICE_CATALOG.flatMap((entry) => - entry.excludeKey === undefined || - entry.excludeKey === "logflare" || - entry.excludeKey === "vector" - ? [] - : ["--exclude", entry.excludeKey], - ); - const start = yield* runSupabaseEffect(["start", ...excludeArgs], { - cwd: projectDir, - exitTimeoutMs: START_TIMEOUT_MS, - }); - - if (start.exitCode !== 0) { - const logs = yield* runDockerEffect(["logs", vectorContainer]).pipe( - Effect.map(({ stdout, stderr }) => `${stdout}${stderr}`.trim() || ""), - Effect.catch((error) => Effect.succeed(``)), - ); - throw new Error( - `start failed (exit ${start.exitCode})\nstdout:\n${start.stdout}\nstderr:\n${start.stderr}\n${vectorContainer} logs:\n${logs}`, - ); - } - - const health = yield* runDockerEffect([ - "inspect", - vectorContainer, - "--format", - "{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}", - ]); - expect(health.stdout.trim()).toBe("healthy"); - }).pipe(Effect.provide(BunServices.layer)), - START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS + CLEANUP_TIMEOUT_MS, - ); }); diff --git a/apps/cli/tests/e2e-timings.json b/apps/cli/tests/e2e-timings.json index 161bf498ed..0594a390ef 100644 --- a/apps/cli/tests/e2e-timings.json +++ b/apps/cli/tests/e2e-timings.json @@ -1,57 +1,61 @@ { "version": 1, "files": { - "scripts/publish-docs-spec.e2e.test.ts": 196, - "src/cli/agent-output.e2e.test.ts": 1179, - "src/cli/complete.e2e.test.ts": 1390, - "src/cli/main.e2e.test.ts": 295, - "src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts": 64985, - "src/commands/completion/completion.e2e.test.ts": 485, - "src/commands/config/diff/diff.e2e.test.ts": 293, - "src/commands/config/pull/pull.e2e.test.ts": 562, - "src/commands/config/push/push.e2e.test.ts": 1016, - "src/commands/db/diff/diff.declarative.e2e.test.ts": 64523, - "src/commands/db/diff/diff.e2e.test.ts": 276, - "src/commands/db/diff/diff.stack-cache.e2e.test.ts": 17572, - "src/commands/db/pull/pull.e2e.test.ts": 235, - "src/commands/db/push/push.e2e.test.ts": 680, - "src/commands/db/reset/reset.e2e.test.ts": 232, - "src/commands/db/reset/reset.stack.e2e.test.ts": 11954, - "src/commands/db/schema/declarative/sync/sync.e2e.test.ts": 86790, - "src/commands/db/start/start.e2e.test.ts": 37852, - "src/commands/db/start/start.roles.e2e.test.ts": 14108, - "src/commands/encryption/encryption.e2e.test.ts": 406, - "src/commands/experimental/stack/start/start.e2e.test.ts": 10026, - "src/commands/feedback/add/add.e2e.test.ts": 176, - "src/commands/functions/deploy/deploy.e2e.test.ts": 1196, - "src/commands/functions/download/download.e2e.test.ts": 846, - "src/commands/functions/serve/serve.stack.e2e.test.ts": 21998, - "src/commands/gen/signing-key/signing-key.e2e.test.ts": 401, - "src/commands/gen/types/types.e2e.test.ts": 3983, - "src/commands/inspect/db/inspect-db.e2e.test.ts": 391, - "src/commands/inspect/report/report.e2e.test.ts": 197, - "src/commands/link/link.e2e.test.ts": 263, - "src/commands/login/login.e2e.test.ts": 437, - "src/commands/logout/logout.e2e.test.ts": 722, - "src/commands/migration/fetch/fetch.e2e.test.ts": 223, - "src/commands/migration/new/new.e2e.test.ts": 189, - "src/commands/migration/squash/squash.e2e.test.ts": 417, - "src/commands/pull/pull.e2e.test.ts": 396, - "src/commands/seed/buckets/buckets.e2e.test.ts": 771, - "src/commands/snippets/snippets.e2e.test.ts": 297, - "src/commands/sso/sso.e2e.test.ts": 1554, - "src/commands/start/start.e2e.test.ts": 199, - "src/commands/start/start.lifecycle.e2e.test.ts": 152494, - "src/commands/start/start.slim-images.e2e.test.ts": 69840, - "src/commands/status/status.e2e.test.ts": 27905, - "src/commands/stop/stop.e2e.test.ts": 59483, - "src/commands/storage/storage.e2e.test.ts": 1072, - "src/commands/test/new/new.e2e.test.ts": 326, - "src/commands/unlink/unlink.e2e.test.ts": 587, - "src/commands/whoami/whoami.e2e.test.ts": 214, - "src/shared/cli/run.e2e.test.ts": 2252, - "src/shared/functions/serve-main-offline.e2e.test.ts": 8693, - "src/shared/telemetry/failure-metadata.e2e.test.ts": 550, - "src/shared/telemetry/posthog-client.e2e.test.ts": 2277 + "scripts/publish-docs-spec.e2e.test.ts": 149, + "src/cli/agent-output.e2e.test.ts": 1737, + "src/cli/complete.e2e.test.ts": 2254, + "src/cli/main.e2e.test.ts": 224, + "src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts": 56277, + "src/commands/completion/completion.e2e.test.ts": 378, + "src/commands/config/diff/diff.e2e.test.ts": 201, + "src/commands/config/pull/pull.e2e.test.ts": 502, + "src/commands/config/push/push.e2e.test.ts": 1061, + "src/commands/db/diff/diff.declarative.e2e.test.ts": 37435, + "src/commands/db/diff/diff.e2e.test.ts": 281, + "src/commands/db/diff/diff.stack-cache.e2e.test.ts": 13101, + "src/commands/db/pull/pull.e2e.test.ts": 282, + "src/commands/db/push/push.e2e.test.ts": 543, + "src/commands/db/reset/reset.e2e.test.ts": 229, + "src/commands/db/reset/reset.stack.e2e.test.ts": 9406, + "src/commands/db/schema/declarative/sync/sync.e2e.test.ts": 134676, + "src/commands/db/start/start.e2e.test.ts": 17188, + "src/commands/db/start/start.roles.e2e.test.ts": 13597, + "src/commands/encryption/encryption.e2e.test.ts": 412, + "src/commands/experimental/stack/start/start.e2e.test.ts": 17247, + "src/commands/experimental/stack/start/start.storage.e2e.test.ts": 18728, + "src/commands/feedback/add/add.e2e.test.ts": 321, + "src/commands/functions/deploy/deploy.e2e.test.ts": 1681, + "src/commands/functions/download/download.e2e.test.ts": 754, + "src/commands/functions/new/new.stack.e2e.test.ts": 12801, + "src/commands/functions/serve/serve.stack.e2e.test.ts": 18895, + "src/commands/gen/signing-key/signing-key.e2e.test.ts": 447, + "src/commands/gen/types/types.e2e.test.ts": 13702, + "src/commands/inspect/db/inspect-db.e2e.test.ts": 646, + "src/commands/inspect/report/report.e2e.test.ts": 249, + "src/commands/link/link.e2e.test.ts": 199, + "src/commands/login/login.e2e.test.ts": 822, + "src/commands/logout/logout.e2e.test.ts": 807, + "src/commands/migration/fetch/fetch.e2e.test.ts": 284, + "src/commands/migration/new/new.e2e.test.ts": 273, + "src/commands/migration/squash/squash.e2e.test.ts": 731, + "src/commands/pull/pull.e2e.test.ts": 614, + "src/commands/seed/buckets/buckets.e2e.test.ts": 1135, + "src/commands/snippets/snippets.e2e.test.ts": 438, + "src/commands/sso/sso.e2e.test.ts": 1695, + "src/commands/start/start.e2e.test.ts": 227, + "src/commands/start/start.health.e2e.test.ts": 86575, + "src/commands/start/start.lifecycle.e2e.test.ts": 114439, + "src/commands/start/start.slim-images.e2e.test.ts": 54510, + "src/commands/status/status.e2e.test.ts": 30548, + "src/commands/stop/stop.e2e.test.ts": 74775, + "src/commands/storage/storage.e2e.test.ts": 996, + "src/commands/test/new/new.e2e.test.ts": 409, + "src/commands/unlink/unlink.e2e.test.ts": 483, + "src/commands/whoami/whoami.e2e.test.ts": 334, + "src/shared/cli/run.e2e.test.ts": 2214, + "src/shared/functions/serve-main-offline.e2e.test.ts": 10115, + "src/shared/telemetry/failure-metadata.e2e.test.ts": 527, + "src/shared/telemetry/posthog-client.e2e.test.ts": 2264, + "src/shared/telemetry/trace-export.e2e.test.ts": 1748 } } From 3a4a7b0b410e1a314f9840c16767e7add885ed76 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:33:45 +0000 Subject: [PATCH 2/4] refactor(cli): cover the `migration` module with effect lint (CLI-2654) (#7117) ## TL;DR brings the `migration` module under the effect lint ## whats introduced? - the `migration*` and `migrate-and-seed` files join the allow list, with the sql helpers they run through - (`postgres-url`, `sql-split`, `param-introspection`, `pg-net-guidance`, `drop-objects`, `pg-dump` and `bundled-postgres-client`), and `postgres-client.run` widens to its tests - `checkScannerBufferSize` reads `SUPABASE_SCANNER_BUFFER_SIZE` through `Config`, still ahead of the project env - migration timestamps go through `DateTime`, and `migration fetch` yields its errors directly with the same messages - the migration and seed tests swap `node:fs` and `process.env` for scoped temp dirs and `withConfigEnv`, and the `pg-dump` and some `migration-history` tests move to `it.effect` ## ref - related: CLI-2654 --- .oxlintrc.effect.json | 11 +- ...undled-postgres-client.integration.test.ts | 2 +- .../migrate-and-seed.unit.test.ts | 669 +++--- .../src/command-internal/migration-apply.ts | 92 +- .../migration-apply.unit.test.ts | 2117 +++++++---------- .../command-internal/migration-fetch-run.ts | 48 +- .../src/command-internal/migration-file.ts | 4 +- .../migration-history.unit.test.ts | 182 +- .../migration-list.unit.test.ts | 232 +- .../migration-timestamp.format.ts | 12 +- .../migration-timestamp.format.unit.test.ts | 5 + .../command-internal/pg-dump.run.unit.test.ts | 52 +- .../command-internal/seed-ops.unit.test.ts | 211 +- .../src/command-internal/seed.unit.test.ts | 241 +- 14 files changed, 1698 insertions(+), 2180 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 2d85399a2f..f485a0b63e 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -24,6 +24,7 @@ "!apps/cli/src/command-internal/api-*", "!apps/cli/src/command-internal/branch-ref.*", "!apps/cli/src/command-internal/branch-target.*", + "!apps/cli/src/command-internal/bundled-postgres-client.*", "!apps/cli/src/command-internal/connect-errors.*", "!apps/cli/src/command-internal/container-cli.*", "!apps/cli/src/command-internal/db-*.ts", @@ -31,6 +32,7 @@ "!apps/cli/src/command-internal/detect-content-type.*", "!apps/cli/src/command-internal/docker-*.ts", "!apps/cli/src/command-internal/dotenv.*", + "!apps/cli/src/command-internal/drop-objects.ts", "!apps/cli/src/command-internal/edge-runtime-*.ts", "!apps/cli/src/command-internal/ensure-login.*", "!apps/cli/src/command-internal/experimental-feature.ts", @@ -46,11 +48,17 @@ "!apps/cli/src/command-internal/local-project-context.*", "!apps/cli/src/command-internal/login-*", "!apps/cli/src/command-internal/management-api-runtime.*", + "!apps/cli/src/command-internal/migrate-and-seed.*", + "!apps/cli/src/command-internal/migration*", + "!apps/cli/src/command-internal/param-introspection.*", "!apps/cli/src/command-internal/parent-project-ref.*", + "!apps/cli/src/command-internal/pg-dump.*", + "!apps/cli/src/command-internal/pg-net-guidance.*", "!apps/cli/src/command-internal/pgpass.*", "!apps/cli/src/command-internal/pgservicefile.*", "!apps/cli/src/command-internal/pooler-fallback.*", - "!apps/cli/src/command-internal/postgres-client.run.ts", + "!apps/cli/src/command-internal/postgres-client.run.*", + "!apps/cli/src/command-internal/postgres-url.*", "!apps/cli/src/command-internal/profile-load.*", "!apps/cli/src/command-internal/profile.*", "!apps/cli/src/command-internal/project-create-core.*", @@ -60,6 +68,7 @@ "!apps/cli/src/command-internal/ref-patterns.*", "!apps/cli/src/command-internal/resolve-token.*", "!apps/cli/src/command-internal/schema-flags.*", + "!apps/cli/src/command-internal/sql-split.*", "!apps/cli/src/command-internal/stack-api.ts", "!apps/cli/src/command-internal/stack-backend.ts", "!apps/cli/src/command-internal/stack-catalog-setup.ts", diff --git a/apps/cli/src/command-internal/bundled-postgres-client.integration.test.ts b/apps/cli/src/command-internal/bundled-postgres-client.integration.test.ts index dee86dfa13..e784f51404 100644 --- a/apps/cli/src/command-internal/bundled-postgres-client.integration.test.ts +++ b/apps/cli/src/command-internal/bundled-postgres-client.integration.test.ts @@ -19,7 +19,7 @@ const processControl = Layer.succeed(ProcessControl, { holdSignals: () => Effect.void, exit: () => Effect.die("unused"), setExitCode: () => Effect.void, - getExitCode: Effect.succeed(undefined), + getExitCode: Effect.undefined, }); const base = Layer.mergeAll(BunServices.layer, processControl); diff --git a/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts b/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts index 9676f442d1..77a9d07a61 100644 --- a/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts +++ b/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts @@ -1,9 +1,6 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { expect, layer } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { stripAnsi } from "../../tests/helpers/ansi.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; @@ -81,15 +78,22 @@ function assertMigrationApplyError(error: unknown): asserts error is MigrationAp } } -function makeWorkdir(): string { - return mkdtempSync(join(tmpdir(), "migrate-and-seed-")); -} +const makeWorkdir = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "migrate-and-seed-" }); +}); -function writeFile(workdir: string, relativePath: string, content: string): void { - const fullPath = join(workdir, relativePath); - mkdirSync(join(fullPath, ".."), { recursive: true }); - writeFileSync(fullPath, content); -} +const writeFile = Effect.fnUntraced(function* ( + workdir: string, + relativePath: string, + content: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fullPath = path.join(workdir, relativePath); + yield* fs.makeDirectory(path.dirname(fullPath), { recursive: true }); + yield* fs.writeFileString(fullPath, content); +}); const baseConfig: MigrateAndSeedConfig = { migrationsEnabled: true, @@ -110,326 +114,284 @@ const run = ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* migrateAndSeed(session, fs, path, workdir, version, config); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer))); + }).pipe(Effect.provide(out.layer)); -describe("migrateAndSeed experimental declarative-schema branch", () => { +layer(BunServices.layer)("migrateAndSeed experimental declarative-schema branch", (it) => { it.effect( "applies schema_paths files instead of migrations when experimental is on, pg-delta is off, and version is empty", - () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); - writeFile( - workdir, - "supabase/migrations/20240101000000_x.sql", - "create table migration_marker ();", - ); - const { session, execs } = fakeSession(); - const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - pgDeltaEnabled: false, - schemaPaths: ["supabase/schemas/a.sql"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("create table schema_marker ()"); - expect(execs).not.toContain("create table migration_marker ()"); - expect(out.rawChunks.map((c) => c.text).join("")).not.toContain("Applying migration"); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + yield* writeFile( + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + const { session, execs } = fakeSession(); + const out = mockOutput(); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: false, + schemaPaths: ["supabase/schemas/a.sql"], + }, + session, + out, + ); + expect(execs).toContain("create table schema_marker ()"); + expect(execs).not.toContain("create table migration_marker ()"); + expect(out.rawChunks.map((c) => c.text).join("")).not.toContain("Applying migration"); + }), ); it.effect( "falls back to migration files when pg-delta is enabled, even with experimental on and an empty version", - () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); - writeFile( + () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + yield* writeFile( + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + const { session, execs } = fakeSession(); + const out = mockOutput(); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: true, + schemaPaths: ["supabase/schemas/a.sql"], + }, + session, + out, + ); + expect(execs).toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + }), + ); + + it.effect("falls back to migration files when experimental is off", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + yield* writeFile( workdir, "supabase/migrations/20240101000000_x.sql", "create table migration_marker ();", ); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( + yield* run( workdir, "", { ...baseConfig, - experimental: true, - pgDeltaEnabled: true, + experimental: false, + pgDeltaEnabled: false, schemaPaths: ["supabase/schemas/a.sql"], }, session, out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("create table migration_marker ()"); - expect(execs).not.toContain("create table schema_marker ()"); - rmSync(workdir, { recursive: true, force: true }); - }), - ), ); - }, + expect(execs).toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + }), ); - it.effect("falls back to migration files when experimental is off", () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); - writeFile( - workdir, - "supabase/migrations/20240101000000_x.sql", - "create table migration_marker ();", - ); - const { session, execs } = fakeSession(); - const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: false, - pgDeltaEnabled: false, - schemaPaths: ["supabase/schemas/a.sql"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("create table migration_marker ()"); - expect(execs).not.toContain("create table schema_marker ()"); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }); - it.effect( "falls back to migration files when a concrete version is passed, even with experimental on", - () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); - writeFile( - workdir, - "supabase/migrations/20240101000000_x.sql", - "create table migration_marker ();", - ); + () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + yield* writeFile( + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + const { session, execs } = fakeSession(); + const out = mockOutput(); + yield* run( + workdir, + "20240101000000", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: false, + schemaPaths: ["supabase/schemas/a.sql"], + }, + session, + out, + ); + expect(execs).toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + }), + ); + + it.effect("still seeds after the declarative-schema branch runs", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + yield* writeFile(workdir, "supabase/seed.sql", "insert into schema_marker default values;"); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( + yield* run( workdir, - "20240101000000", + "", { ...baseConfig, experimental: true, pgDeltaEnabled: false, schemaPaths: ["supabase/schemas/a.sql"], + // schemaPaths/sqlPaths arrive already `supabase/`-prefixed from their real caller. + seed: { enabled: true, sqlPaths: ["supabase/seed.sql"] }, }, session, out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("create table migration_marker ()"); - expect(execs).not.toContain("create table schema_marker ()"); - rmSync(workdir, { recursive: true, force: true }); - }), - ), ); - }, + expect(execs).toContain("create table schema_marker ()"); + expect(execs).toContain("insert into schema_marker default values"); + }), ); - it.effect("still seeds after the declarative-schema branch runs", () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); - writeFile(workdir, "supabase/seed.sql", "insert into schema_marker default values;"); - const { session, execs } = fakeSession(); - const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - pgDeltaEnabled: false, - schemaPaths: ["supabase/schemas/a.sql"], - // schemaPaths/sqlPaths arrive already `supabase/`-prefixed from their real caller. - seed: { enabled: true, sqlPaths: ["supabase/seed.sql"] }, - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("create table schema_marker ()"); - expect(execs).toContain("insert into schema_marker default values"); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }); - it.effect( "expands a directory schema_paths entry to its .sql files, recursively, in declared order", - () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/z_function.sql", "select 1;"); - writeFile(workdir, "supabase/schemas/tables/a_table.sql", "select 2;"); - writeFile(workdir, "supabase/schemas/tables/nested/b_table.sql", "select 3;"); - writeFile(workdir, "supabase/schemas/tables/readme.md", "ignored"); + () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* writeFile(workdir, "supabase/schemas/z_function.sql", "select 1;"); + yield* writeFile(workdir, "supabase/schemas/tables/a_table.sql", "select 2;"); + yield* writeFile(workdir, "supabase/schemas/tables/nested/b_table.sql", "select 3;"); + yield* writeFile(workdir, "supabase/schemas/tables/readme.md", "ignored"); + const { session, execs } = fakeSession(); + const out = mockOutput(); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: ["supabase/schemas/z_function.sql", "supabase/schemas/tables"], + }, + session, + out, + ); + const order = execs.filter((sql) => sql.startsWith("select ")); + expect(order).toEqual(["select 1", "select 2", "select 3"]); + }), + ); + + it.effect("deduplicates an explicit file also matched by a directory/glob pattern", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* writeFile(workdir, "supabase/database/a.sql", "select 10;"); + yield* writeFile(workdir, "supabase/database/b.sql", "select 20;"); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( + yield* run( workdir, "", { ...baseConfig, experimental: true, - schemaPaths: ["supabase/schemas/z_function.sql", "supabase/schemas/tables"], + schemaPaths: ["supabase/database/a.sql", "supabase/database", "supabase/database/*.sql"], }, session, out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - const order = execs.filter((sql) => sql.startsWith("select ")); - expect(order).toEqual(["select 1", "select 2", "select 3"]); - rmSync(workdir, { recursive: true, force: true }); - }), - ), ); - }, + const order = execs.filter((sql) => sql.startsWith("select ")); + // Each file applied exactly once, in sorted order — not once per matching pattern. + expect(order).toEqual(["select 10", "select 20"]); + }), ); - it.effect("deduplicates an explicit file also matched by a directory/glob pattern", () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/database/a.sql", "select 10;"); - writeFile(workdir, "supabase/database/b.sql", "select 20;"); - const { session, execs } = fakeSession(); - const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - schemaPaths: ["supabase/database/a.sql", "supabase/database", "supabase/database/*.sql"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - const order = execs.filter((sql) => sql.startsWith("select ")); - // Each file applied exactly once, in sorted order — not once per matching pattern. - expect(order).toEqual(["select 10", "select 20"]); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }); - it.effect.skipIf(isRoot)( "fails a matched schema_paths directory that cannot be traversed, instead of treating it as empty", - () => { - // Mode 000 makes `stat` (parent-directory lookup) succeed but `readdir` fail with EACCES, - // which `applySchemaFiles` propagates as "failed to walk matched directory". - const workdir = makeWorkdir(); - const lockedDir = join(workdir, "supabase", "schemas", "locked"); - mkdirSync(lockedDir, { recursive: true }); - writeFileSync(join(lockedDir, "b.sql"), "select 1;"); - chmodSync(lockedDir, 0o000); - const { session } = fakeSession(); - const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - schemaPaths: ["supabase/schemas/locked"], - }, - session, - out, - ).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to walk matched directory"); - } - chmodSync(lockedDir, 0o755); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + // Mode 000 makes `stat` (parent-directory lookup) succeed but `readdir` fail with EACCES, + // which `applySchemaFiles` propagates as "failed to walk matched directory". + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* makeWorkdir; + const lockedDir = path.join(workdir, "supabase", "schemas", "locked"); + yield* writeFile(workdir, "supabase/schemas/locked/b.sql", "select 1;"); + yield* Effect.acquireRelease(fs.chmod(lockedDir, 0o000), () => + fs.chmod(lockedDir, 0o755).pipe(Effect.orDie), + ); + const { session } = fakeSession(); + const out = mockOutput(); + const error = yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: ["supabase/schemas/locked"], + }, + session, + out, + ).pipe(Effect.flip); + expect(error.message).toContain("failed to walk matched directory"); + }), ); it.effect( "skips a symlinked .sql file and an entire symlinked subdirectory inside a matched schema_paths directory", - () => { - // Symlinked files and directories are never followed, regardless of target; both live - // outside the matched directory here, so applying either would mean executing SQL that - // should never run. - const workdir = makeWorkdir(); - const outsideDir = mkdtempSync(join(tmpdir(), "migrate-and-seed-outside-")); - writeFileSync(join(outsideDir, "escaped.sql"), "select 999;"); - writeFileSync(join(outsideDir, "linked-target.sql"), "select 888;"); - writeFile(workdir, "supabase/schemas/real.sql", "select 1;"); - symlinkSync( - join(outsideDir, "linked-target.sql"), - join(workdir, "supabase", "schemas", "link-to-file.sql"), - ); - symlinkSync(outsideDir, join(workdir, "supabase", "schemas", "link-to-dir")); - const { session, execs } = fakeSession(); - const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - schemaPaths: ["supabase/schemas"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("select 1"); - expect(execs).not.toContain("select 888"); - expect(execs).not.toContain("select 999"); - rmSync(workdir, { recursive: true, force: true }); - rmSync(outsideDir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + // Symlinked files and directories are never followed, regardless of target; both live + // outside the matched directory here, so applying either would mean executing SQL that + // should never run. + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* makeWorkdir; + const outsideDir = yield* fs.makeTempDirectoryScoped({ + prefix: "migrate-and-seed-outside-", + }); + yield* fs.writeFileString(path.join(outsideDir, "escaped.sql"), "select 999;"); + yield* fs.writeFileString(path.join(outsideDir, "linked-target.sql"), "select 888;"); + yield* writeFile(workdir, "supabase/schemas/real.sql", "select 1;"); + yield* fs.symlink( + path.join(outsideDir, "linked-target.sql"), + path.join(workdir, "supabase", "schemas", "link-to-file.sql"), + ); + yield* fs.symlink(outsideDir, path.join(workdir, "supabase", "schemas", "link-to-dir")); + const { session, execs } = fakeSession(); + const out = mockOutput(); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: ["supabase/schemas"], + }, + session, + out, + ); + expect(execs).toContain("select 1"); + expect(execs).not.toContain("select 888"); + expect(execs).not.toContain("select 999"); + }), ); - it.effect( - "attaches the failing schema file as Go's CmdSuggestion (See schema file: )", - () => { - const workdir = makeWorkdir(); + it.effect("attaches the failing schema file as Go's CmdSuggestion (See schema file: )", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; const schemaPath = "supabase/schemas/broken.sql"; - writeFile(workdir, schemaPath, "totally not valid sql;"); + yield* writeFile(workdir, schemaPath, "totally not valid sql;"); const { session } = failingExecSession(); const out = mockOutput(); - return run( + const error = yield* run( workdir, "", { @@ -439,23 +401,15 @@ describe("migrateAndSeed experimental declarative-schema branch", () => { }, session, out, - ).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error).toBeInstanceOf(MigrationApplyError); - const suggestion = (error as MigrationApplyError).suggestion; - expect(suggestion).toBeDefined(); - expect(stripAnsi(suggestion ?? "")).toBe(`See schema file: ${schemaPath}`); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }, + ).pipe(Effect.flip); + assertMigrationApplyError(error); + expect(error.suggestion).toBeDefined(); + expect(stripAnsi(error.suggestion ?? "")).toBe(`See schema file: ${schemaPath}`); + }), ); }); -describe("migrateAndSeed local pg_net remediation", () => { +layer(BunServices.layer)("migrateAndSeed local pg_net remediation", (it) => { const missingNetSchema = new DbExecError({ message: 'ERROR: schema "net" does not exist (SQLSTATE 3F000)', code: "3F000", @@ -468,89 +422,80 @@ describe("migrateAndSeed local pg_net remediation", () => { "select net.http_post(url := 'https://example.com');", ); - it.effect("suggests enabling Database Webhooks when local replay cannot find pg_net", () => { - const workdir = makeWorkdir(); - setupMigration(workdir); - const out = mockOutput(); - return run( - workdir, - "", - { ...baseConfig, localDatabaseWebhooksEnabled: false }, - pgNetFailureSession(missingNetSchema), - out, - ).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - assertMigrationApplyError(error); - expect(error.suggestion).toBe(ENABLE_LOCAL_WEBHOOKS_SUGGESTION); - expect(error[ErrorActionabilityId]).toEqual(actionability.invalidConfig); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("suggests enabling Database Webhooks when local replay cannot find pg_net", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* setupMigration(workdir); + const out = mockOutput(); + const error = yield* run( + workdir, + "", + { ...baseConfig, localDatabaseWebhooksEnabled: false }, + pgNetFailureSession(missingNetSchema), + out, + ).pipe(Effect.flip); + assertMigrationApplyError(error); + expect(error.suggestion).toBe(ENABLE_LOCAL_WEBHOOKS_SUGGESTION); + expect(error[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + }), + ); - it.effect("does not add the local hint when webhooks are enabled", () => { - const workdir = makeWorkdir(); - setupMigration(workdir); - const out = mockOutput(); - return run( - workdir, - "", - { ...baseConfig, localDatabaseWebhooksEnabled: true }, - pgNetFailureSession(missingNetSchema), - out, - ).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - assertMigrationApplyError(error); - expect(error.suggestion).toBeUndefined(); - expect(error[ErrorActionabilityId]).toEqual(actionability.dbFinding); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("does not add the local hint when webhooks are enabled", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* setupMigration(workdir); + const out = mockOutput(); + const error = yield* run( + workdir, + "", + { ...baseConfig, localDatabaseWebhooksEnabled: true }, + pgNetFailureSession(missingNetSchema), + out, + ).pipe(Effect.flip); + assertMigrationApplyError(error); + expect(error.suggestion).toBeUndefined(); + expect(error[ErrorActionabilityId]).toEqual(actionability.dbFinding); + }), + ); - it.effect("does not add the local hint for migration commands without local context", () => { - const workdir = makeWorkdir(); - setupMigration(workdir); - const out = mockOutput(); - return run(workdir, "", baseConfig, pgNetFailureSession(missingNetSchema), out).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - assertMigrationApplyError(error); - expect(error.suggestion).toBeUndefined(); - expect(error[ErrorActionabilityId]).toEqual(actionability.dbFinding); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("does not add the local hint for migration commands without local context", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + yield* setupMigration(workdir); + const out = mockOutput(); + const error = yield* run( + workdir, + "", + baseConfig, + pgNetFailureSession(missingNetSchema), + out, + ).pipe(Effect.flip); + assertMigrationApplyError(error); + expect(error.suggestion).toBeUndefined(); + expect(error[ErrorActionabilityId]).toEqual(actionability.dbFinding); + }), + ); }); -describe("migrateAndSeed apply order", () => { - it.effect("applies mixed-width versions in version order, like db push (#6036)", () => { - const workdir = makeWorkdir(); - // File-name order would sort `20260420010000_b.sql` before `20260420_a.sql` ('0' < '_'), - // the reverse of version order — this asserts the migrations replay in version order. - writeFile(workdir, "supabase/migrations/20260420_a.sql", "create table t (id int);"); - writeFile(workdir, "supabase/migrations/20260420010000_b.sql", "alter table t add c int;"); - const { session, execs } = fakeSession(); - const out = mockOutput(); - return run(workdir, "", baseConfig, session, out).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs.filter((sql) => sql.includes("table t"))).toEqual([ - "create table t (id int)", - "alter table t add c int", - ]); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }); +layer(BunServices.layer)("migrateAndSeed apply order", (it) => { + it.effect("applies mixed-width versions in version order, like db push (#6036)", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + // File-name order would sort `20260420010000_b.sql` before `20260420_a.sql` ('0' < '_'), + // the reverse of version order — this asserts the migrations replay in version order. + yield* writeFile(workdir, "supabase/migrations/20260420_a.sql", "create table t (id int);"); + yield* writeFile( + workdir, + "supabase/migrations/20260420010000_b.sql", + "alter table t add c int;", + ); + const { session, execs } = fakeSession(); + const out = mockOutput(); + yield* run(workdir, "", baseConfig, session, out); + expect(execs.filter((sql) => sql.includes("table t"))).toEqual([ + "create table t (id int)", + "alter table t add c int", + ]); + }), + ); }); diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index 96aea47001..197f02cfbb 100644 --- a/apps/cli/src/command-internal/migration-apply.ts +++ b/apps/cli/src/command-internal/migration-apply.ts @@ -1,4 +1,4 @@ -import { Data, Effect, type FileSystem, type Path } from "effect"; +import { Config, Data, Effect, Option, type FileSystem, type Path } from "effect"; import { Output } from "../shared/output/output.service.ts"; import { bold } from "./colors.ts"; @@ -263,50 +263,54 @@ export const checkScannerBufferSize = ( content: string, mapError: (message: string, phase: "read" | "exec") => E, projectEnv: Readonly> = {}, -): Effect.Effect => { - const raw = - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] ?? projectEnv["SUPABASE_SCANNER_BUFFER_SIZE"]; - if (raw === undefined) return Effect.void; - const configuredLimit = parseScannerBufferSize(raw); - // Covers both an explicit non-positive size and an unparseable value (see - // `GO_DEFAULT_MAX_SCANNER_CAPACITY` above) — both fall back to the hardcoded default cap, not - // to "no limit". - const limit = - configuredLimit > 0 - ? Math.max(configuredLimit, GO_SCANNER_START_BUF_SIZE) - : GO_DEFAULT_MAX_SCANNER_CAPACITY; - // The reported limit is the raw configured value, even below the `GO_SCANNER_START_BUF_SIZE` - // floor (which only affects when the too-long error can fire, not the number reported), or the - // hardcoded default once that's been fallen back to. - const reportedLimit = configuredLimit > 0 ? configuredLimit : GO_DEFAULT_MAX_SCANNER_CAPACITY; - let emitted = 0; - let lastRaw = ""; - for (const token of splitSqlTokens(content)) { - // A terminated token exactly at `limit` still succeeds (only strictly-over fails, `>`); an - // unterminated trailing token at `limit` already fails (`>=`), since there's no delimiter left - // to find once the buffer fills without one. - const tooLong = token.terminated - ? utf8ByteLength(token.raw) > limit - : utf8ByteLength(token.raw) >= limit; - if (tooLong) { - const suggestion = `Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is ${Math.floor(reportedLimit / 1024)}KB)`; - return Effect.fail( - mapError( - `bufio.Scanner: token too long\nAfter statement ${emitted}: ${lastRaw}\n${suggestion}`, - "read", - ), - ); - } - // `lastRaw` updates on every scanned token, even ones that trim to empty and don't advance - // `emitted` — so a lone `;` right before an oversized statement reports it accurately instead - // of a blank token. - lastRaw = token.raw; - if (token.trimmed.length > 0) { - emitted += 1; +): Effect.Effect => + Effect.gen(function* () { + const shellValue = yield* Config.option(Config.String("SUPABASE_SCANNER_BUFFER_SIZE")).pipe( + Effect.mapError(() => + mapError("failed to resolve environment variable: SUPABASE_SCANNER_BUFFER_SIZE", "read"), + ), + ); + const raw = Option.getOrUndefined(shellValue) ?? projectEnv["SUPABASE_SCANNER_BUFFER_SIZE"]; + if (raw === undefined) return; + const configuredLimit = parseScannerBufferSize(raw); + // Covers both an explicit non-positive size and an unparseable value (see + // `GO_DEFAULT_MAX_SCANNER_CAPACITY` above) — both fall back to the hardcoded default cap, not + // to "no limit". + const limit = + configuredLimit > 0 + ? Math.max(configuredLimit, GO_SCANNER_START_BUF_SIZE) + : GO_DEFAULT_MAX_SCANNER_CAPACITY; + // The reported limit is the raw configured value, even below the `GO_SCANNER_START_BUF_SIZE` + // floor (which only affects when the too-long error can fire, not the number reported), or the + // hardcoded default once that's been fallen back to. + const reportedLimit = configuredLimit > 0 ? configuredLimit : GO_DEFAULT_MAX_SCANNER_CAPACITY; + let emitted = 0; + let lastRaw = ""; + for (const token of splitSqlTokens(content)) { + // A terminated token exactly at `limit` still succeeds (only strictly-over fails, `>`); an + // unterminated trailing token at `limit` already fails (`>=`), since there's no delimiter + // left to find once the buffer fills without one. + const tooLong = token.terminated + ? utf8ByteLength(token.raw) > limit + : utf8ByteLength(token.raw) >= limit; + if (tooLong) { + const suggestion = `Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is ${Math.floor(reportedLimit / 1024)}KB)`; + return yield* Effect.fail( + mapError( + `bufio.Scanner: token too long\nAfter statement ${emitted}: ${lastRaw}\n${suggestion}`, + "read", + ), + ); + } + // `lastRaw` updates on every scanned token, even ones that trim to empty and don't advance + // `emitted` — so a lone `;` right before an oversized statement reports it accurately + // instead of a blank token. + lastRaw = token.raw; + if (token.trimmed.length > 0) { + emitted += 1; + } } - } - return Effect.void; -}; + }); /** * Renders a `^` caret line under the error position of a failing statement. `pos` is the diff --git a/apps/cli/src/command-internal/migration-apply.unit.test.ts b/apps/cli/src/command-internal/migration-apply.unit.test.ts index e1b33615b0..3119211c83 100644 --- a/apps/cli/src/command-internal/migration-apply.unit.test.ts +++ b/apps/cli/src/command-internal/migration-apply.unit.test.ts @@ -1,10 +1,8 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Exit, FileSystem, Path } from "effect"; +import { describe, expect, it, layer } from "@effect/vitest"; +import { ConfigProvider, Data, Effect, Exit, FileSystem, Path, Schema } from "effect"; +import { withConfigEnv, withEmptyConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import { actionability, @@ -111,10 +109,21 @@ const executedSql = ( : [], ); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const tempFile = Effect.fnUntraced(function* (name: string, contents: string, prefix = "apply-") { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix }); + const file = path.join(dir, name); + yield* fs.writeFileString(file, contents); + return file; +}); + const run = ( session: DbSession, migrationPath: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -125,742 +134,559 @@ const run = ( migrationPath, (message) => new TestError({ message }), ); - }).pipe(Effect.provide(BunServices.layer)); + }); -describe("applyMigrationFile", () => { +layer(BunServices.layer)("applyMigrationFile", (it) => { it.effect( "creates the history table, then runs the statements + history insert in a transaction", - () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_add_col.sql"); - writeFileSync(file, "ALTER TABLE a ADD COLUMN b int;\nCREATE INDEX i ON a(b);"); - const { session, calls } = fakeSession(); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const execs = executedSql(calls); - expect(execs).toContain("CREATE SCHEMA IF NOT EXISTS supabase_migrations"); - expect(execs).toContain("RESET ALL"); - expect(execs[0]).toBe("RESET ALL"); - const firstBegin = execs.indexOf("BEGIN"); - const setupCommit = execs.indexOf("COMMIT"); - const setLocal = execs.indexOf("SET LOCAL lock_timeout = '4s'"); - expect(firstBegin).toBe(1); - expect(setLocal).toBeGreaterThan(firstBegin); - expect(setLocal).toBeLessThan(setupCommit); - const migrationBatch = calls.find((call) => call.kind === "batch"); - expect(migrationBatch?.statements?.map(({ sql }) => sql)).toEqual([ - "ALTER TABLE a ADD COLUMN b int", - "CREATE INDEX i ON a(b)", - expect.stringContaining("supabase_migrations.schema_migrations"), - ]); - const insert = migrationBatch?.statements?.at(-1); - expect(insert?.sql).toContain("supabase_migrations.schema_migrations"); - expect(insert?.params).toEqual([ - "20240101120000", - "add_col", - ["ALTER TABLE a ADD COLUMN b int", "CREATE INDEX i ON a(b)"], - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_add_col.sql", + "ALTER TABLE a ADD COLUMN b int;\nCREATE INDEX i ON a(b);", + ); + const { session, calls } = fakeSession(); + yield* run(session, file); + const execs = executedSql(calls); + expect(execs).toContain("CREATE SCHEMA IF NOT EXISTS supabase_migrations"); + expect(execs).toContain("RESET ALL"); + expect(execs[0]).toBe("RESET ALL"); + const firstBegin = execs.indexOf("BEGIN"); + const setupCommit = execs.indexOf("COMMIT"); + const setLocal = execs.indexOf("SET LOCAL lock_timeout = '4s'"); + expect(firstBegin).toBe(1); + expect(setLocal).toBeGreaterThan(firstBegin); + expect(setLocal).toBeLessThan(setupCommit); + const migrationBatch = calls.find((call) => call.kind === "batch"); + expect(migrationBatch?.statements?.map(({ sql }) => sql)).toEqual([ + "ALTER TABLE a ADD COLUMN b int", + "CREATE INDEX i ON a(b)", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + const insert = migrationBatch?.statements?.at(-1); + expect(insert?.sql).toContain("supabase_migrations.schema_migrations"); + expect(insert?.params).toEqual([ + "20240101120000", + "add_col", + ["ALTER TABLE a ADD COLUMN b int", "CREATE INDEX i ON a(b)"], + ]); + }), ); - it.effect("records a versioned empty migration in one batch", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_empty.sql"); - writeFileSync(file, ""); - const { session, calls } = fakeSession(); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const batches = calls.filter((call) => call.kind === "batch"); - expect(batches).toHaveLength(1); - expect(batches[0]?.statements).toEqual([ - { - sql: expect.stringContaining("supabase_migrations.schema_migrations"), - params: ["20240101120000", "empty", []], - }, - ]); - }), - ), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + it.effect("records a versioned empty migration in one batch", () => + Effect.gen(function* () { + const file = yield* tempFile("20240101120000_empty.sql", ""); + const { session, calls } = fakeSession(); + yield* run(session, file); + const batches = calls.filter((call) => call.kind === "batch"); + expect(batches).toHaveLength(1); + expect(batches[0]?.statements).toEqual([ + { + sql: expect.stringContaining("supabase_migrations.schema_migrations"), + params: ["20240101120000", "empty", []], + }, + ]); + }), + ); - it.effect("rolls back and maps the error when a statement fails", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_boom.sql"); - writeFileSync(file, "ALTER TABLE a ADD COLUMN b int;"); - const { session, calls } = fakeSession({ failOn: "ADD COLUMN b int" }); - return run(session, file).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - expect(calls.filter((call) => call.kind === "batch")).toHaveLength(1); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("At statement: 0"); - expect(msg).toContain("ALTER TABLE a ADD COLUMN b int"); - } - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("rolls back and maps the error when a statement fails", () => + Effect.gen(function* () { + const file = yield* tempFile("20240101120000_boom.sql", "ALTER TABLE a ADD COLUMN b int;"); + const { session, calls } = fakeSession({ failOn: "ADD COLUMN b int" }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(calls.filter((call) => call.kind === "batch")).toHaveLength(1); + expect(error.message).toContain("At statement: 0"); + expect(error.message).toContain("ALTER TABLE a ADD COLUMN b int"); + }), + ); - it.effect("surfaces a lost batch connection verbatim, never as a failing statement", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_lost.sql"); - writeFileSync(file, "ALTER TABLE a ADD COLUMN b int;"); - const { session } = fakeSession({ - batchConnectionLost: "connection to the database was lost before the batch could be sent", - }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error).toBeInstanceOf(DbConnectError); - expect(error.message).toBe( - "connection to the database was lost before the batch could be sent", - ); - const rendered = JSON.stringify(error); - expect(rendered).not.toContain("At statement:"); - expect(rendered).not.toContain("ALTER TABLE a ADD COLUMN b int"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("surfaces a lost batch connection verbatim, never as a failing statement", () => + Effect.gen(function* () { + const file = yield* tempFile("20240101120000_lost.sql", "ALTER TABLE a ADD COLUMN b int;"); + const { session } = fakeSession({ + batchConnectionLost: "connection to the database was lost before the batch could be sent", + }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error).toBeInstanceOf(DbConnectError); + expect(error.message).toBe( + "connection to the database was lost before the batch could be sent", + ); + const rendered = yield* encodeJson(error); + expect(rendered).not.toContain("At statement:"); + expect(rendered).not.toContain("ALTER TABLE a ADD COLUMN b int"); + }), + ); - it.effect("sends a large compatible migration in one batch", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_many.sql"); - const statements = Array.from({ length: 10_000 }, (_, index) => `SELECT ${index + 1}`); - writeFileSync(file, `${statements.join(";\n")};`); - const { session, calls } = fakeSession(); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const batches = calls.filter((call) => call.kind === "batch"); - expect(batches).toHaveLength(1); - expect(batches[0]?.statements).toHaveLength(statements.length + 1); - expect(batches[0]?.statements?.slice(0, -1).map(({ sql }) => sql)).toEqual(statements); - expect(calls.some((call) => call.kind === "exec" && statements.includes(call.sql))).toBe( - false, - ); - }), - ), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + it.effect("sends a large compatible migration in one batch", () => + Effect.gen(function* () { + const statements = Array.from({ length: 10_000 }, (_, index) => `SELECT ${index + 1}`); + const file = yield* tempFile("20240101120000_many.sql", `${statements.join(";\n")};`); + const { session, calls } = fakeSession(); + yield* run(session, file); + const batches = calls.filter((call) => call.kind === "batch"); + expect(batches).toHaveLength(1); + expect(batches[0]?.statements).toHaveLength(statements.length + 1); + expect(batches[0]?.statements?.slice(0, -1).map(({ sql }) => sql)).toEqual(statements); + expect(calls.some((call) => call.kind === "exec" && statements.includes(call.sql))).toBe( + false, + ); + }), + ); - it.effect("keeps the global error index after an incompatible-statement flush", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_fail_after_vacuum.sql"); - writeFileSync(file, "SELECT 1;\nVACUUM;\nSELECT missing_column;\nSELECT 4;"); - const { session } = fakeSession({ failOn: "missing_column" }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("At statement: 2"); - expect(error.message).toContain("SELECT missing_column"); - }), - ), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + it.effect("keeps the global error index after an incompatible-statement flush", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_fail_after_vacuum.sql", + "SELECT 1;\nVACUUM;\nSELECT missing_column;\nSELECT 4;", + ); + const { session } = fakeSession({ failOn: "missing_column" }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("At statement: 2"); + expect(error.message).toContain("SELECT missing_column"); + }), + ); - it.effect("defaults a deferred batch failure to the migration history statement", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_deferred.sql"); - writeFileSync(file, "SELECT 1;"); - const { session } = fakeSession({ failAfterBatch: true }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("At statement: 2"); - expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); - }), - ), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + it.effect("defaults a deferred batch failure to the migration history statement", () => + Effect.gen(function* () { + const file = yield* tempFile("20240101120000_deferred.sql", "SELECT 1;"); + const { session } = fakeSession({ failAfterBatch: true }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("At statement: 2"); + expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + }), + ); it.effect( "wraps a read failure with Go's parse-file error text (Go NewMigrationFromFile parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "apply-read-fail-")); - const missingFile = join(dir, "20240101120000_missing.sql"); - const { session } = fakeSession(); - return run(session, missingFile).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("failed to open migration file: "); - } - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "apply-read-fail-" }); + const missingFile = path.join(dir, "20240101120000_missing.sql"); + const { session } = fakeSession(); + const error = yield* run(session, missingFile).pipe(Effect.flip); + expect(error.message).toContain("failed to open migration file: "); + }), ); - it.effect("runs a pipeline-incompatible statement outside the surrounding transaction", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_add_index.sql"); - writeFileSync( - file, - "create table a (id int);\nCREATE INDEX CONCURRENTLY a_idx ON a(id);\nALTER TABLE a ENABLE ROW LEVEL SECURITY;", - ); - const { session, calls } = fakeSession(); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const execs = executedSql(calls); - const concurrently = "CREATE INDEX CONCURRENTLY a_idx ON a(id)"; - expect(execs).toContain(concurrently); - const batches = calls.filter((call) => call.kind === "batch"); - expect(batches).toHaveLength(2); - expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual([ - "create table a (id int)", - ]); - expect(batches[1]?.statements?.map(({ sql }) => sql)).toEqual([ - "ALTER TABLE a ENABLE ROW LEVEL SECURITY", - expect.stringContaining("supabase_migrations.schema_migrations"), - ]); - const insert = batches[1]?.statements?.at(-1); - expect(insert?.params?.[0]).toBe("20240101120000"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("runs a pipeline-incompatible statement outside the surrounding transaction", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_add_index.sql", + "create table a (id int);\nCREATE INDEX CONCURRENTLY a_idx ON a(id);\nALTER TABLE a ENABLE ROW LEVEL SECURITY;", + ); + const { session, calls } = fakeSession(); + yield* run(session, file); + const execs = executedSql(calls); + const concurrently = "CREATE INDEX CONCURRENTLY a_idx ON a(id)"; + expect(execs).toContain(concurrently); + const batches = calls.filter((call) => call.kind === "batch"); + expect(batches).toHaveLength(2); + expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual(["create table a (id int)"]); + expect(batches[1]?.statements?.map(({ sql }) => sql)).toEqual([ + "ALTER TABLE a ENABLE ROW LEVEL SECURITY", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + const insert = batches[1]?.statements?.at(-1); + expect(insert?.params?.[0]).toBe("20240101120000"); + }), + ); - it.effect("honors pg-delta's file-level no-transaction directive", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_drop_subscription.sql"); - writeFileSync( - file, - "-- pg-delta: transaction=false\n" + - "SET check_function_bodies = off;\n" + - "DROP SUBSCRIPTION app_events;\n" + - "RESET ALL;", - ); - const { session, calls } = fakeSession(); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); - const setupCommit = execs.indexOf("COMMIT"); - const firstStatement = "-- pg-delta: transaction=false\nSET check_function_bodies = off"; - const set = execs.indexOf(firstStatement); - const action = execs.indexOf("DROP SUBSCRIPTION app_events"); - const cleanup = execs.lastIndexOf("RESET ALL"); - - expect(execs.filter((sql) => sql === "BEGIN")).toHaveLength(1); - expect(execs.filter((sql) => sql === "COMMIT")).toHaveLength(1); - expect(set).toBeGreaterThan(setupCommit); - expect(action).toBeGreaterThan(set); - expect(cleanup).toBeGreaterThan(action); - - const history = calls.filter( - (call) => call.kind === "query" && call.params !== undefined, - ); - expect(history).toHaveLength(1); - expect(history[0]?.params).toEqual([ - "20240101120000", - "drop_subscription", - [firstStatement, "DROP SUBSCRIPTION app_events", "RESET ALL"], - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("honors pg-delta's file-level no-transaction directive", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_drop_subscription.sql", + "-- pg-delta: transaction=false\n" + + "SET check_function_bodies = off;\n" + + "DROP SUBSCRIPTION app_events;\n" + + "RESET ALL;", + ); + const { session, calls } = fakeSession(); + yield* run(session, file); + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + const setupCommit = execs.indexOf("COMMIT"); + const firstStatement = "-- pg-delta: transaction=false\nSET check_function_bodies = off"; + const set = execs.indexOf(firstStatement); + const action = execs.indexOf("DROP SUBSCRIPTION app_events"); + const cleanup = execs.lastIndexOf("RESET ALL"); + + expect(execs.filter((sql) => sql === "BEGIN")).toHaveLength(1); + expect(execs.filter((sql) => sql === "COMMIT")).toHaveLength(1); + expect(set).toBeGreaterThan(setupCommit); + expect(action).toBeGreaterThan(set); + expect(cleanup).toBeGreaterThan(action); + + const history = calls.filter((call) => call.kind === "query" && call.params !== undefined); + expect(history).toHaveLength(1); + expect(history[0]?.params).toEqual([ + "20240101120000", + "drop_subscription", + [firstStatement, "DROP SUBSCRIPTION app_events", "RESET ALL"], + ]); + }), + ); - it.effect("resets the session and omits history when a no-transaction migration fails", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_drop_subscription.sql"); - writeFileSync( - file, - "-- pg-delta: transaction=false\n" + - "SET check_function_bodies = off;\n" + - "DROP SUBSCRIPTION app_events;\n" + - "RESET ALL;", - ); - const { session, calls } = fakeSession({ failOn: "DROP SUBSCRIPTION" }); - return run(session, file).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); - expect(execs.at(-1)).toBe("RESET ALL"); - expect(calls.some((call) => call.kind === "query" && call.params !== undefined)).toBe( - false, - ); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("At statement: 1"); - } - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("resets the session and omits history when a no-transaction migration fails", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_drop_subscription.sql", + "-- pg-delta: transaction=false\n" + + "SET check_function_bodies = off;\n" + + "DROP SUBSCRIPTION app_events;\n" + + "RESET ALL;", + ); + const { session, calls } = fakeSession({ failOn: "DROP SUBSCRIPTION" }); + const error = yield* run(session, file).pipe(Effect.flip); + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + expect(execs.at(-1)).toBe("RESET ALL"); + expect(calls.some((call) => call.kind === "query" && call.params !== undefined)).toBe(false); + expect(error.message).toContain("At statement: 1"); + }), + ); - it.effect("reports a pipeline-incompatible statement failure with its statement index", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_add_index.sql"); - writeFileSync(file, "create table a (id int);\nCREATE INDEX CONCURRENTLY a_idx ON a(id);"); - const { session, calls } = fakeSession({ failOn: "CONCURRENTLY" }); - return run(session, file).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("At statement: 1"); - expect(msg).toContain("CREATE INDEX CONCURRENTLY a_idx ON a(id)"); - } - expect( - executedSql(calls).some((sql) => - sql.includes("INSERT INTO supabase_migrations.schema_migrations"), - ), - ).toBe(false); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("reports a pipeline-incompatible statement failure with its statement index", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_add_index.sql", + "create table a (id int);\nCREATE INDEX CONCURRENTLY a_idx ON a(id);", + ); + const { session, calls } = fakeSession({ failOn: "CONCURRENTLY" }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("At statement: 1"); + expect(error.message).toContain("CREATE INDEX CONCURRENTLY a_idx ON a(id)"); + expect( + executedSql(calls).some((sql) => + sql.includes("INSERT INTO supabase_migrations.schema_migrations"), + ), + ).toBe(false); + }), + ); - it.effect("preserves authored transaction boundaries and records history afterwards", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_authored.sql"); - writeFileSync(file, "BEGIN;\nSET LOCAL check_function_bodies = off;\nCOMMIT;"); - const { session, calls } = fakeSession(); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); - expect(execs.filter((sql) => sql === "BEGIN")).toHaveLength(2); - expect(execs.filter((sql) => sql === "COMMIT")).toHaveLength(2); - expect(execs).toContain("SET LOCAL check_function_bodies = off"); - const history = calls.filter( - (call) => call.kind === "query" && call.params !== undefined, - ); - expect(history).toHaveLength(1); - expect(history[0]?.params?.[0]).toBe("20240101120000"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("preserves authored transaction boundaries and records history afterwards", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_authored.sql", + "BEGIN;\nSET LOCAL check_function_bodies = off;\nCOMMIT;", + ); + const { session, calls } = fakeSession(); + yield* run(session, file); + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + expect(execs.filter((sql) => sql === "BEGIN")).toHaveLength(2); + expect(execs.filter((sql) => sql === "COMMIT")).toHaveLength(2); + expect(execs).toContain("SET LOCAL check_function_bodies = off"); + const history = calls.filter((call) => call.kind === "query" && call.params !== undefined); + expect(history).toHaveLength(1); + expect(history[0]?.params?.[0]).toBe("20240101120000"); + }), + ); - it.effect("keeps savepoint rollback inside the managed migration transaction", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_savepoint.sql"); - writeFileSync( - file, - "SAVEPOINT before_change;\n" + - "UPDATE accounts SET active = false;\n" + - "ROLLBACK TO SAVEPOINT before_change;\n" + - "SELECT 1;", - ); - const { session, calls } = fakeSession(); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const batches = calls.filter((call) => call.kind === "batch"); - expect(batches).toHaveLength(1); - expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual([ - "SAVEPOINT before_change", - "UPDATE accounts SET active = false", - "ROLLBACK TO SAVEPOINT before_change", - "SELECT 1", - expect.stringContaining("supabase_migrations.schema_migrations"), - ]); - expect( - calls.filter((call) => call.kind === "query" && call.params !== undefined), - ).toHaveLength(0); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("keeps savepoint rollback inside the managed migration transaction", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_savepoint.sql", + "SAVEPOINT before_change;\n" + + "UPDATE accounts SET active = false;\n" + + "ROLLBACK TO SAVEPOINT before_change;\n" + + "SELECT 1;", + ); + const { session, calls } = fakeSession(); + yield* run(session, file); + const batches = calls.filter((call) => call.kind === "batch"); + expect(batches).toHaveLength(1); + expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual([ + "SAVEPOINT before_change", + "UPDATE accounts SET active = false", + "ROLLBACK TO SAVEPOINT before_change", + "SELECT 1", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + expect( + calls.filter((call) => call.kind === "query" && call.params !== undefined), + ).toHaveLength(0); + }), + ); - it.effect("does not record history when an authored transaction fails", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_authored.sql"); - writeFileSync(file, "BEGIN;\nCREATE TABLE broken (;\nCOMMIT;"); - const { session, calls } = fakeSession({ failOn: "CREATE TABLE broken" }); - return run(session, file).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - expect(calls.some((call) => call.kind === "query" && call.params !== undefined)).toBe( - false, - ); - expect(calls.some((call) => call.kind === "exec" && call.sql === "ROLLBACK")).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("does not record history when an authored transaction fails", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_authored.sql", + "BEGIN;\nCREATE TABLE broken (;\nCOMMIT;", + ); + const { session, calls } = fakeSession({ failOn: "CREATE TABLE broken" }); + const exit = yield* run(session, file).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(calls.some((call) => call.kind === "query" && call.params !== undefined)).toBe(false); + expect(calls.some((call) => call.kind === "exec" && call.sql === "ROLLBACK")).toBe(true); + }), + ); - it.effect( - "re-asserts the stepped-down role between the statements and the history insert", - () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_reset_role.sql"); - writeFileSync(file, "set role repro_writer;\ncreate table t (id int);\nreset role;"); - const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const batch = calls.find((call) => call.kind === "batch"); - expect(batch?.statements?.map(({ sql }) => sql)).toEqual([ - "set role repro_writer", - "create table t (id int)", - "reset role", - "SET SESSION ROLE postgres", - expect.stringContaining("supabase_migrations.schema_migrations"), - ]); - expect(batch?.statements?.at(-1)?.params?.[2]).toEqual([ - "set role repro_writer", - "create table t (id int)", - "reset role", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + it.effect("re-asserts the stepped-down role between the statements and the history insert", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_reset_role.sql", + "set role repro_writer;\ncreate table t (id int);\nreset role;", ); - }, + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + yield* run(session, file); + const batch = calls.find((call) => call.kind === "batch"); + expect(batch?.statements?.map(({ sql }) => sql)).toEqual([ + "set role repro_writer", + "create table t (id int)", + "reset role", + "SET SESSION ROLE postgres", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + expect(batch?.statements?.at(-1)?.params?.[2]).toEqual([ + "set role repro_writer", + "create table t (id int)", + "reset role", + ]); + }), ); - it.effect("never re-asserts a role on sessions that did not step down", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_reset_role.sql"); - writeFileSync(file, "reset role;"); - const { session, calls } = fakeSession(); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(executedSql(calls).some((sql) => sql.includes("SET SESSION ROLE"))).toBe(false); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("never re-asserts a role on sessions that did not step down", () => + Effect.gen(function* () { + const file = yield* tempFile("20240101120000_reset_role.sql", "reset role;"); + const { session, calls } = fakeSession(); + yield* run(session, file); + expect(executedSql(calls).some((sql) => sql.includes("SET SESSION ROLE"))).toBe(false); + }), + ); - it.effect("re-asserts the stepped-down role before recording an authored transaction", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_authored.sql"); - writeFileSync(file, "BEGIN;\nreset role;\nCOMMIT;"); - const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const lastRestore = calls.findLastIndex( - (call) => call.kind === "exec" && call.sql === "SET SESSION ROLE postgres", - ); - const authoredCommit = calls.findLastIndex( - (call) => call.kind === "exec" && call.sql === "COMMIT", - ); - const history = calls.findIndex( - (call) => call.kind === "query" && call.params !== undefined, - ); - expect(lastRestore).toBeGreaterThan(authoredCommit); - expect(history).toBeGreaterThan(lastRestore); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("re-asserts the stepped-down role before recording an authored transaction", () => + Effect.gen(function* () { + const file = yield* tempFile("20240101120000_authored.sql", "BEGIN;\nreset role;\nCOMMIT;"); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + yield* run(session, file); + const lastRestore = calls.findLastIndex( + (call) => call.kind === "exec" && call.sql === "SET SESSION ROLE postgres", + ); + const authoredCommit = calls.findLastIndex( + (call) => call.kind === "exec" && call.sql === "COMMIT", + ); + const history = calls.findIndex((call) => call.kind === "query" && call.params !== undefined); + expect(lastRestore).toBeGreaterThan(authoredCommit); + expect(history).toBeGreaterThan(lastRestore); + }), + ); - it.effect("keeps the history insert's statement index when the role restore precedes it", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_fail.sql"); - writeFileSync(file, "SELECT 1;"); - const { session } = fakeSession({ - restoreRoleSql: "SET SESSION ROLE postgres", - failOn: "INSERT INTO supabase_migrations", - failWith: { - message: "ERROR: permission denied for schema supabase_migrations (SQLSTATE 42501)", - code: "42501", - }, - }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("permission denied for schema supabase_migrations"); - expect(error.message).toContain("At statement: 1"); - expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("keeps the history insert's statement index when the role restore precedes it", () => + Effect.gen(function* () { + const file = yield* tempFile("20240101120000_fail.sql", "SELECT 1;"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "INSERT INTO supabase_migrations", + failWith: { + message: "ERROR: permission denied for schema supabase_migrations (SQLSTATE 42501)", + code: "42501", + }, + }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("permission denied for schema supabase_migrations"); + expect(error.message).toContain("At statement: 1"); + expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + }), + ); - it.effect("keeps a mid-batch failure's statement index when a restore op is appended", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_fail.sql"); - writeFileSync(file, "SELECT 1;\nSELECT bad_col;\nSELECT 3;"); - const { session } = fakeSession({ - restoreRoleSql: "SET SESSION ROLE postgres", - failOn: "bad_col", - }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("At statement: 1"); - expect(error.message).toContain("SELECT bad_col"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("keeps a mid-batch failure's statement index when a restore op is appended", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_fail.sql", + "SELECT 1;\nSELECT bad_col;\nSELECT 3;", + ); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "bad_col", + }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("At statement: 1"); + expect(error.message).toContain("SELECT bad_col"); + }), + ); - it.effect("keeps the deferred-failure index when a restore op is appended", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_deferred.sql"); - writeFileSync(file, "SELECT 1;"); - const { session } = fakeSession({ - restoreRoleSql: "SET SESSION ROLE postgres", - failAfterBatch: true, - }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("At statement: 2"); - expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("keeps the deferred-failure index when a restore op is appended", () => + Effect.gen(function* () { + const file = yield* tempFile("20240101120000_deferred.sql", "SELECT 1;"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failAfterBatch: true, + }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("At statement: 2"); + expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + }), + ); - it.effect("reports the restore op's own failure with the history step's index", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_fail.sql"); - writeFileSync(file, "SELECT 1;"); - const { session } = fakeSession({ - restoreRoleSql: "SET SESSION ROLE postgres", - failOn: "SET SESSION ROLE", - failWith: { - message: 'ERROR: permission denied to set role "postgres" (SQLSTATE 42501)', - code: "42501", - }, - }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("At statement: 1"); - expect(error.message).toContain("SET SESSION ROLE postgres"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("reports the restore op's own failure with the history step's index", () => + Effect.gen(function* () { + const file = yield* tempFile("20240101120000_fail.sql", "SELECT 1;"); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "SET SESSION ROLE", + failWith: { + message: 'ERROR: permission denied to set role "postgres" (SQLSTATE 42501)', + code: "42501", + }, + }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("At statement: 1"); + expect(error.message).toContain("SET SESSION ROLE postgres"); + }), + ); - it.effect("keeps the insert index when the final batch holds only the trailing ops", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_fail.sql"); - writeFileSync(file, "SELECT 1;\nCREATE INDEX CONCURRENTLY i ON a(id);"); - const { session } = fakeSession({ - restoreRoleSql: "SET SESSION ROLE postgres", - failOn: "INSERT INTO supabase_migrations", - }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("At statement: 2"); - expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("keeps the insert index when the final batch holds only the trailing ops", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_fail.sql", + "SELECT 1;\nCREATE INDEX CONCURRENTLY i ON a(id);", + ); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "INSERT INTO supabase_migrations", + }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("At statement: 2"); + expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + }), + ); it.effect("restores postgres immediately after a mid-file RESET ROLE, silently", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_reset_role.sql"); - writeFileSync(file, "set role r;\nreset role;\nselect 1;"); - const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); const out = mockOutput({ format: "text" }); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const batch = calls.find((call) => call.kind === "batch"); - expect(batch?.statements?.map(({ sql }) => sql)).toEqual([ - "set role r", - "reset role", - "SET SESSION ROLE postgres", - "select 1", - "SET SESSION ROLE postgres", - expect.stringContaining("supabase_migrations.schema_migrations"), - ]); - expect(batch?.statements?.at(-1)?.params?.[2]).toEqual([ - "set role r", - "reset role", - "select 1", - ]); - expect(out.stderrText).not.toContain("WARN:"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(out.layer), - ); + return Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_reset_role.sql", + "set role r;\nreset role;\nselect 1;", + ); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + yield* run(session, file); + const batch = calls.find((call) => call.kind === "batch"); + expect(batch?.statements?.map(({ sql }) => sql)).toEqual([ + "set role r", + "reset role", + "SET SESSION ROLE postgres", + "select 1", + "SET SESSION ROLE postgres", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + expect(batch?.statements?.at(-1)?.params?.[2]).toEqual([ + "set role r", + "reset role", + "select 1", + ]); + expect(out.stderrText).not.toContain("WARN:"); + }).pipe(Effect.provide(out.layer)); }); - it.effect("restores postgres after every static role-revert spelling", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_role_none.sql"); - writeFileSync( - file, - "set role a;\nset role none;\nset role to none;\nreset session authorization;\nset role = default;", - ); - const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const batch = calls.find((call) => call.kind === "batch"); - expect(batch?.statements?.map(({ sql }) => sql)).toEqual([ - "set role a", - "set role none", - "SET SESSION ROLE postgres", - "set role to none", - "SET SESSION ROLE postgres", - "reset session authorization", - "SET SESSION ROLE postgres", - "set role = default", - "SET SESSION ROLE postgres", - expect.stringContaining("supabase_migrations.schema_migrations"), - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("restores postgres after every static role-revert spelling", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_role_none.sql", + "set role a;\nset role none;\nset role to none;\nreset session authorization;\nset role = default;", + ); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + yield* run(session, file); + const batch = calls.find((call) => call.kind === "batch"); + expect(batch?.statements?.map(({ sql }) => sql)).toEqual([ + "set role a", + "set role none", + "SET SESSION ROLE postgres", + "set role to none", + "SET SESSION ROLE postgres", + "reset session authorization", + "SET SESSION ROLE postgres", + "set role = default", + "SET SESSION ROLE postgres", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + }), + ); - it.effect("emits exactly one restore when a no-transaction file ends in a revert", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_seq_reset.sql"); - writeFileSync(file, "-- pg-delta: transaction=false\nset role r;\nreset role;"); - 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.filter((sql) => sql === "SET SESSION ROLE postgres")).toHaveLength(1); - expect(execs[execs.indexOf("reset role") + 1]).toBe("SET SESSION ROLE postgres"); - expect( - calls.filter((call) => call.kind === "query" && call.params !== undefined), - ).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("emits exactly one restore when a no-transaction file ends in a revert", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_seq_reset.sql", + "-- pg-delta: transaction=false\nset role r;\nreset role;", + ); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + yield* run(session, file); + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + expect(execs.filter((sql) => sql === "SET SESSION ROLE postgres")).toHaveLength(1); + expect(execs[execs.indexOf("reset role") + 1]).toBe("SET SESSION ROLE postgres"); + expect( + calls.filter((call) => call.kind === "query" && call.params !== undefined), + ).toHaveLength(1); + }), + ); - it.effect("keeps the deferred-failure index when mid-file restores were injected", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_deferred.sql"); - writeFileSync(file, "set role r;\nreset role;\nselect 1;"); - const { session } = fakeSession({ - restoreRoleSql: "SET SESSION ROLE postgres", - failAfterBatch: true, - }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("At statement: 4"); - expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("keeps the deferred-failure index when mid-file restores were injected", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_deferred.sql", + "set role r;\nreset role;\nselect 1;", + ); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failAfterBatch: true, + }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("At statement: 4"); + expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + }), + ); - it.effect("injects into intermediate flushes so standalone statements run as postgres", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_concurrent.sql"); - writeFileSync(file, "reset role;\nCREATE INDEX CONCURRENTLY i ON a(id);\nselect 2;"); - const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); - return run(session, file).pipe( - Effect.tap(() => - Effect.sync(() => { - const batches = calls.filter((call) => call.kind === "batch"); - expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual([ - "reset role", - "SET SESSION ROLE postgres", - ]); - 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("injects into intermediate flushes so standalone statements run as postgres", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_concurrent.sql", + "reset role;\nCREATE INDEX CONCURRENTLY i ON a(id);\nselect 2;", + ); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + yield* run(session, file); + const batches = calls.filter((call) => call.kind === "batch"); + expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual([ + "reset role", + "SET SESSION ROLE postgres", + ]); + expect(batches[1]?.statements?.map(({ sql }) => sql)).toEqual([ + "select 2", + "SET SESSION ROLE postgres", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + }), + ); - it.effect("reports a mid-file restore's own failure at its host statement", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_fail.sql"); - writeFileSync(file, "set role r;\nreset role;\nselect 1;"); - const { session } = fakeSession({ - restoreRoleSql: "SET SESSION ROLE postgres", - failOn: "SET SESSION ROLE", - }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("At statement: 1"); - expect(error.message).toContain("SET SESSION ROLE postgres"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("reports a mid-file restore's own failure at its host statement", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_fail.sql", + "set role r;\nreset role;\nselect 1;", + ); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "SET SESSION ROLE", + }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("At statement: 1"); + expect(error.message).toContain("SET SESSION ROLE postgres"); + }), + ); - it.effect("keeps statement numbering across an injected mid-file restore", () => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_fail.sql"); - writeFileSync(file, "set role r;\nreset role;\nselect bad;"); - const { session } = fakeSession({ - restoreRoleSql: "SET SESSION ROLE postgres", - failOn: "select bad", - }); - return run(session, file).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.message).toContain("At statement: 2"); - expect(error.message).toContain("select bad"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("keeps statement numbering across an injected mid-file restore", () => + Effect.gen(function* () { + const file = yield* tempFile( + "20240101120000_fail.sql", + "set role r;\nreset role;\nselect bad;", + ); + const { session } = fakeSession({ + restoreRoleSql: "SET SESSION ROLE postgres", + failOn: "select bad", + }); + const error = yield* run(session, file).pipe(Effect.flip); + expect(error.message).toContain("At statement: 2"); + expect(error.message).toContain("select bad"); + }), + ); }); describe("hasTransactionControl", () => { @@ -887,116 +713,97 @@ describe("hasTransactionControl", () => { }); }); -describe("migration failure rendering (Go ExecBatch parity)", () => { +layer(BunServices.layer)("migration failure rendering (Go ExecBatch parity)", (it) => { // Error message layout: `\n[Detail]\n[42704 hint]\n` then // `At statement: \n`. const failing = ( sql: string, failWith: { message: string; code?: string; detail?: string; position?: number }, - ) => { - const dir = mkdtempSync(join(tmpdir(), "apply-")); - const file = join(dir, "20240101120000_fail.sql"); - writeFileSync(file, `${sql};`); - const { session } = fakeSession({ failOn: sql, failWith }); - return run(session, file).pipe( - Effect.flip, - Effect.map((error) => error.message), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }; + ) => + Effect.gen(function* () { + const file = yield* tempFile("20240101120000_fail.sql", `${sql};`); + const { session } = fakeSession({ failOn: sql, failWith }); + const error = yield* run(session, file).pipe(Effect.flip); + return error.message; + }); - it.effect("marks the error position with a caret under the failing statement", () => { - const stat = "CREATE TABLE test (path ltree NOT NULL)"; - return failing(stat, { - message: 'ERROR: type "ltree" does not exist (SQLSTATE 42704)', - code: "42704", - position: 25, - }).pipe( - Effect.tap((message) => - Effect.sync(() => { - expect(message).toBe( - 'ERROR: type "ltree" does not exist (SQLSTATE 42704)\n' + - "\n" + - "Hint: This type may be defined in a schema that's not in your search_path.\n" + - " Use schema-qualified type references to avoid this error:\n" + - " CREATE TABLE example (col extensions.ltree);\n" + - " Learn more: supabase migration new --help\n" + - "At statement: 0\n" + - "CREATE TABLE test (path ltree NOT NULL)\n" + - " ^", - ); - }), - ), - ); - }); + it.effect("marks the error position with a caret under the failing statement", () => + Effect.gen(function* () { + const stat = "CREATE TABLE test (path ltree NOT NULL)"; + const message = yield* failing(stat, { + message: 'ERROR: type "ltree" does not exist (SQLSTATE 42704)', + code: "42704", + position: 25, + }); + expect(message).toBe( + 'ERROR: type "ltree" does not exist (SQLSTATE 42704)\n' + + "\n" + + "Hint: This type may be defined in a schema that's not in your search_path.\n" + + " Use schema-qualified type references to avoid this error:\n" + + " CREATE TABLE example (col extensions.ltree);\n" + + " Learn more: supabase migration new --help\n" + + "At statement: 0\n" + + "CREATE TABLE test (path ltree NOT NULL)\n" + + " ^", + ); + }), + ); - it.effect("renders the server Detail line before the statement context", () => { - const stat = "INSERT INTO child VALUES (1)"; - return failing(stat, { - message: - 'ERROR: insert or update on table "child" violates foreign key constraint "child_parent_id_fkey" (SQLSTATE 23503)', - code: "23503", - detail: 'Key (parent_id)=(1) is not present in table "parent".', - }).pipe( - Effect.tap((message) => - Effect.sync(() => { - expect(message).toBe( - 'ERROR: insert or update on table "child" violates foreign key constraint "child_parent_id_fkey" (SQLSTATE 23503)\n' + - 'Key (parent_id)=(1) is not present in table "parent".\n' + - "At statement: 0\n" + - "INSERT INTO child VALUES (1)", - ); - }), - ), - ); - }); + it.effect("renders the server Detail line before the statement context", () => + Effect.gen(function* () { + const stat = "INSERT INTO child VALUES (1)"; + const message = yield* failing(stat, { + message: + 'ERROR: insert or update on table "child" violates foreign key constraint "child_parent_id_fkey" (SQLSTATE 23503)', + code: "23503", + detail: 'Key (parent_id)=(1) is not present in table "parent".', + }); + expect(message).toBe( + 'ERROR: insert or update on table "child" violates foreign key constraint "child_parent_id_fkey" (SQLSTATE 23503)\n' + + 'Key (parent_id)=(1) is not present in table "parent".\n' + + "At statement: 0\n" + + "INSERT INTO child VALUES (1)", + ); + }), + ); - it.effect("skips the hint when the SQLSTATE is not 42704", () => { - const stat = "CREATE TABLE test (path ltree NOT NULL)"; - return failing(stat, { - message: 'ERROR: type "ltree" does not exist (SQLSTATE 42P01)', - code: "42P01", - }).pipe( - Effect.tap((message) => - Effect.sync(() => { - expect(message).toBe( - 'ERROR: type "ltree" does not exist (SQLSTATE 42P01)\n' + - "At statement: 0\n" + - "CREATE TABLE test (path ltree NOT NULL)", - ); - }), - ), - ); - }); + it.effect("skips the hint when the SQLSTATE is not 42704", () => + Effect.gen(function* () { + const stat = "CREATE TABLE test (path ltree NOT NULL)"; + const message = yield* failing(stat, { + message: 'ERROR: type "ltree" does not exist (SQLSTATE 42P01)', + code: "42P01", + }); + expect(message).toBe( + 'ERROR: type "ltree" does not exist (SQLSTATE 42P01)\n' + + "At statement: 0\n" + + "CREATE TABLE test (path ltree NOT NULL)", + ); + }), + ); - it.effect("skips the 42704 hint when the type is already schema-qualified", () => { - const stat = "CREATE TABLE test (path extensions.ltree NOT NULL)"; - return failing(stat, { - message: 'ERROR: type "extensions.ltree" does not exist (SQLSTATE 42704)', - code: "42704", - }).pipe( - Effect.tap((message) => - Effect.sync(() => { - expect(message).toBe( - 'ERROR: type "extensions.ltree" does not exist (SQLSTATE 42704)\n' + - "At statement: 0\n" + - "CREATE TABLE test (path extensions.ltree NOT NULL)", - ); - }), - ), - ); - }); + it.effect("skips the 42704 hint when the type is already schema-qualified", () => + Effect.gen(function* () { + const stat = "CREATE TABLE test (path extensions.ltree NOT NULL)"; + const message = yield* failing(stat, { + message: 'ERROR: type "extensions.ltree" does not exist (SQLSTATE 42704)', + code: "42704", + }); + expect(message).toBe( + 'ERROR: type "extensions.ltree" does not exist (SQLSTATE 42704)\n' + + "At statement: 0\n" + + "CREATE TABLE test (path extensions.ltree NOT NULL)", + ); + }), + ); - it.effect("keeps the plain layout for errors without position or detail", () => { - const stat = "ALTER TABLE a ADD COLUMN b int"; - return failing(stat, { message: "exec failed" }).pipe( - Effect.tap((message) => - Effect.sync(() => { - expect(message).toBe("exec failed\nAt statement: 0\nALTER TABLE a ADD COLUMN b int"); - }), - ), - ); - }); + it.effect("keeps the plain layout for errors without position or detail", () => + Effect.gen(function* () { + const stat = "ALTER TABLE a ADD COLUMN b int"; + const message = yield* failing(stat, { message: "exec failed" }); + expect(message).toBe("exec failed\nAt statement: 0\nALTER TABLE a ADD COLUMN b int"); + }), + ); }); describe("markError", () => { @@ -1103,15 +910,13 @@ describe("isPipelineIncompatible", () => { }); }); -describe("seedGlobals", () => { - it.effect("runs the globals file WITHOUT RESET ALL and without a history insert", () => { - const dir = mkdtempSync(join(tmpdir(), "globals-")); - const file = join(dir, "roles.sql"); - writeFileSync(file, "CREATE ROLE my_role;"); - const { session, calls } = fakeSession(); - return Effect.gen(function* () { +layer(BunServices.layer)("seedGlobals", (it) => { + it.effect("runs the globals file WITHOUT RESET ALL and without a history insert", () => + Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const file = yield* tempFile("roles.sql", "CREATE ROLE my_role;", "globals-"); + const { session, calls } = fakeSession(); yield* seedGlobals(session, fs, path, [file], (message) => new TestError({ message })); const execs = executedSql(calls); expect(execs).not.toContain("RESET ALL"); @@ -1119,516 +924,324 @@ describe("seedGlobals", () => { expect( execs.some((sql) => sql.includes("INSERT INTO supabase_migrations.schema_migrations")), ).toBe(false); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(BunServices.layer), - ); - }); + }).pipe(Effect.provide(mockOutput({ format: "text" }).layer)), + ); - it.effect("leaves a stepped-down session role-clean after a globals file", () => { - // Globals run on the same session as the later vault upsert and history-table DDL, so a role - // must not leak into them. - const dir = mkdtempSync(join(tmpdir(), "globals-")); - const file = join(dir, "roles.sql"); - writeFileSync(file, "CREATE ROLE my_role;\nset role my_role;\nreset role;"); - const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); - return Effect.gen(function* () { + it.effect("leaves a stepped-down session role-clean after a globals file", () => + Effect.gen(function* () { + // Globals run on the same session as the later vault upsert and history-table DDL, so a role + // must not leak into them. const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const file = yield* tempFile( + "roles.sql", + "CREATE ROLE my_role;\nset role my_role;\nreset role;", + "globals-", + ); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); yield* seedGlobals(session, fs, path, [file], (message) => new TestError({ message })); const batch = calls.find((call) => call.kind === "batch"); expect(batch?.statements?.at(-1)?.sql).toBe("SET SESSION ROLE postgres"); expect( executedSql(calls).some((sql) => sql.includes("supabase_migrations.schema_migrations")), ).toBe(false); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(BunServices.layer), - ); - }); + }).pipe(Effect.provide(mockOutput({ format: "text" }).layer)), + ); }); -describe("applySchemaFiles", () => { +layer(BunServices.layer)("applySchemaFiles", (it) => { + const schemaFile = Effect.fnUntraced(function* (prefix: string, name: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix }); + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + const file = path.join(workdir, "supabase", name); + yield* fs.writeFileString(file, contents); + return { workdir, file }; + }); + + const applySchema = ( + session: DbSession, + workdir: string, + schemaPath: string, + projectEnv?: Readonly>, + ) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* applySchemaFiles( + session, + fs, + path, + workdir, + [schemaPath], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + projectEnv, + ); + }); + it.effect( "reports a read failure with the workdir-relative path, not the absolute path used to read it (Go open supabase/... parity)", - () => { - // An unreadable file (permission denied, not missing) still passes the glob's own - // stat/type check, since that only needs directory execute permission, not read access to - // the file itself. - const dir = mkdtempSync(join(tmpdir(), "schema-files-read-fail-")); - const file = join(dir, "supabase", "unreadable.sql"); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(file, "select 1;"); - chmodSync(file, 0o000); - const { session } = fakeSession(); - return Effect.gen(function* () { + () => + Effect.gen(function* () { + // An unreadable file (permission denied, not missing) still passes the glob's own + // stat/type check, since that only needs directory execute permission, not read access to + // the file itself. const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/unreadable.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("failed to open migration file: "); - expect(msg).toContain("supabase/unreadable.sql"); - expect(msg).not.toContain(dir); - } - chmodSync(file, 0o644); - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + const { workdir, file } = yield* schemaFile( + "schema-files-read-fail-", + "unreadable.sql", + "select 1;", + ); + yield* Effect.acquireRelease(fs.chmod(file, 0o000), () => + fs.chmod(file, 0o644).pipe(Effect.orDie), + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/unreadable.sql").pipe( + Effect.flip, + ); + expect(error.message).toContain("failed to open migration file: "); + expect(error.message).toContain("supabase/unreadable.sql"); + expect(error.message).not.toContain(workdir); + }), ); it.effect( "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go bufio.Scanner: token too long parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - // Exceeds the 4096-byte floor the scanner always starts at, regardless of the configured - // limit. - writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); - expect(msg).toContain("After statement 1: SELECT 1;"); - expect(msg).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB"); - } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, + () => + Effect.gen(function* () { + // Exceeds the 4096-byte floor the scanner always starts at, regardless of the configured + // limit. + const { workdir } = yield* schemaFile( + "schema-files-scanner-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("bufio.Scanner: token too long"); + expect(error.message).toContain("After statement 1: SELECT 1;"); + expect(error.message).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB"); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), ); it.effect( "reports the last scanned RAW token in the too-long error even when it trimmed to empty (Go scanner.Text() parity, review CLI-1958)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-empty-token-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `;\nSELECT '${"a".repeat(5000)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); - expect(msg).toContain("After statement 0: ;"); - } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-empty-token-", + "big.sql", + `;\nSELECT '${"a".repeat(5000)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("bufio.Scanner: token too long"); + expect(error.message).toContain("After statement 0: ;"); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), ); it.effect( "applies an oversized statement fine when SUPABASE_SCANNER_BUFFER_SIZE is unset (Go's default auto-grows to file size)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-default-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT '${"a".repeat(5000)}';\n`); - const { session, calls } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-default-", + "big.sql", + `SELECT '${"a".repeat(5000)}';\n`, ); + const { session, calls } = fakeSession(); + yield* applySchema(session, workdir, "supabase/big.sql"); expect(executedSql(calls).some((sql) => sql.startsWith("SELECT 'a"))).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous !== undefined) process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, + }).pipe(withEmptyConfigEnv), ); it.effect( "falls back to Go's hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE is set but unparseable (viper parity, not '5M' == 5MiB)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-garbage-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(300_000)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "5M"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); - expect(msg).toContain( - "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", - ); - } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-garbage-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(300_000)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("bufio.Scanner: token too long"); + expect(error.message).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", + ); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "5M" }, body)), ); it.effect( "accepts a hex-literal SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt base-0 parity, review CLI-1958)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-hex-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "0x1400"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); - expect(msg).toContain( - "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", - ); - } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-hex-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("bufio.Scanner: token too long"); + expect(error.message).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", + ); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "0x1400" }, body)), ); it.effect( "accepts underscore digit separators in a decimal SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt base-0 underscore-literal parity, review CLI-1958)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-underscore-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "5_120"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); - expect(msg).toContain( - "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", - ); - } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-underscore-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("bufio.Scanner: token too long"); + expect(error.message).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", + ); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "5_120" }, body)), ); it.effect( "rejects an invalid underscore placement in SUPABASE_SCANNER_BUFFER_SIZE, unlike a valid digit separator (Go strconv.ParseInt underscore-grammar parity, review CLI-1958)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-bad-underscore-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "_5120"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-bad-underscore-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`, + ); + const { session } = fakeSession(); + const exit = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "_5120" }, body)), ); it.effect( "falls back to Go's hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE overflows Go's signed int range (strconv.ParseInt/cast.ToInt range-error parity, review CLI-1958)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-int64-overflow-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(300_000)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "9223372036854775808"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); - expect(msg).toContain( - "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", - ); - } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-int64-overflow-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(300_000)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("bufio.Scanner: token too long"); + expect(error.message).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", + ); + }).pipe((body) => + withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "9223372036854775808" }, body), + ), ); it.effect( "still accepts the exact int64 boundary magnitudes for SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt range-boundary parity, review CLI-1958)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-int64-boundary-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "9223372036854775807"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-int64-boundary-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`, + ); + const { session } = fakeSession(); + const exit = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, + }).pipe((body) => + withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "9223372036854775807" }, body), + ), ); it.effect( "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is set only in the project env (Go loadNestedEnv parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-projectenv-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); - } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous !== undefined) process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-projectenv-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql", { + SUPABASE_SCANNER_BUFFER_SIZE: "100b", + }).pipe(Effect.flip); + expect(error.message).toContain("bufio.Scanner: token too long"); + }).pipe(withEmptyConfigEnv), ); it.effect( "shell env still wins over the project env for SUPABASE_SCANNER_BUFFER_SIZE (Go godotenv 'never overrides' parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-shellwins-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT '${"a".repeat(5000)}';\n`); - const { session, calls } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - // "0" is treated as unset (no check); the shell value must still win over the project - // env's tiny limit. - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "0"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-shellwins-", + "big.sql", + `SELECT '${"a".repeat(5000)}';\n`, ); + const { session, calls } = fakeSession(); + // "0" is treated as unset (no check); the shell value must still win over the project + // env's tiny limit. + yield* applySchema(session, workdir, "supabase/big.sql", { + SUPABASE_SCANNER_BUFFER_SIZE: "100b", + }); expect(executedSql(calls).some((sql) => sql.startsWith("SELECT 'a"))).toBe(true); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "0" }, body)), + ); + + it.effect( + "keeps an empty shell SUPABASE_SCANNER_BUFFER_SIZE ahead of the project env, at the default cap", + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-empty-shell-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(300_000)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql", { + SUPABASE_SCANNER_BUFFER_SIZE: "100b", + }).pipe(Effect.flip); + expect(error.message).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", + ); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "" }, body)), + ); + + it.effect( + "fails with a read-phase error when SUPABASE_SCANNER_BUFFER_SIZE cannot be resolved", + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-config-error-", + "big.sql", + "SELECT 1;\n", + ); + const { session, calls } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toBe( + "failed to resolve environment variable: SUPABASE_SCANNER_BUFFER_SIZE", + ); + expect(executedSql(calls)).toEqual([]); }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), + Effect.provide( + ConfigProvider.layer( + ConfigProvider.make(() => + Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), + ), + ), ), - Effect.provide(BunServices.layer), - ); - }, + ), ); }); diff --git a/apps/cli/src/command-internal/migration-fetch-run.ts b/apps/cli/src/command-internal/migration-fetch-run.ts index 0390201928..c18cf7ddc9 100644 --- a/apps/cli/src/command-internal/migration-fetch-run.ts +++ b/apps/cli/src/command-internal/migration-fetch-run.ts @@ -50,11 +50,9 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( // Validated first, before any other work — same ordering as `migration down`/`repair`. if (target.setFlags.length > 1) { - return yield* Effect.fail( - new MigrationTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }), - ); + return yield* new MigrationTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }); } const connType = target.connType ?? "linked"; // fetch defaults to `--linked`. @@ -64,12 +62,10 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( // discarded on a non-linked target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new MigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new MigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // Resolve the DB config BEFORE any filesystem/prompt side effects — an invalid @@ -93,15 +89,13 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( // Linked fetch caches the project ref on success. The ref is // loaded now (pre-run), but the cache write is attached to the body via `Effect.ensuring`, // so a declined prompt returns before it runs. - const cacheLinkedRef = - connType === "linked" - ? yield* Effect.gen(function* () { - const projectRef = yield* ProjectRefResolver; - const linkedProjectCache = yield* LinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(flags.projectRef); - return linkedProjectCache.cache(ref); - }) - : undefined; + let cacheLinkedRef: Effect.Effect | undefined; + if (connType === "linked") { + const projectRef = yield* ProjectRefResolver; + const linkedProjectCache = yield* LinkedProjectCache; + const ref = yield* projectRef.loadProjectRef(flags.projectRef); + cacheLinkedRef = linkedProjectCache.cache(ref); + } const fetchBody = Effect.gen(function* () { const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); @@ -129,9 +123,7 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( ? assumeYes : yield* migrationConfirm(title, { defaultValue: true, yes }); if (!overwrite) { - return yield* Effect.fail( - new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); } } @@ -160,12 +152,10 @@ export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( const escapes = (segment: string) => /[/\\]/u.test(segment) || segment.split(/[/\\]/u).includes(".."); if (escapes(file.version) || escapes(file.name)) { - return yield* Effect.fail( - new MigrationFetchWriteError({ - message: `failed to write migration: invalid version/name in history table: ${file.version}_${file.name}`, - writtenSoFar: [...written], - }), - ); + return yield* new MigrationFetchWriteError({ + message: `failed to write migration: invalid version/name in history table: ${file.version}_${file.name}`, + writtenSoFar: [...written], + }); } const name = `${file.version}_${file.name}.sql`; const filePath = path.join(migrationsDir, name); diff --git a/apps/cli/src/command-internal/migration-file.ts b/apps/cli/src/command-internal/migration-file.ts index 2a4669916f..21fe99b8c1 100644 --- a/apps/cli/src/command-internal/migration-file.ts +++ b/apps/cli/src/command-internal/migration-file.ts @@ -1,4 +1,4 @@ -import type { Path } from "effect"; +import { DateTime, type Path } from "effect"; import { splitAndTrim } from "./sql-split.ts"; @@ -43,7 +43,7 @@ export function parseMigrationContent(content: string): ParsedMigrationContent { * `Clock.currentTimeMillis`) so it stays deterministic under test. */ export function formatMigrationTimestamp(millis: number): string { - return new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 14); + return DateTime.formatIso(DateTime.makeUnsafe(millis)).replace(/\D/gu, "").slice(0, 14); } /** diff --git a/apps/cli/src/command-internal/migration-history.unit.test.ts b/apps/cli/src/command-internal/migration-history.unit.test.ts index 944292147d..2c37b61abe 100644 --- a/apps/cli/src/command-internal/migration-history.unit.test.ts +++ b/apps/cli/src/command-internal/migration-history.unit.test.ts @@ -1,5 +1,5 @@ +import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; -import { describe, expect, it } from "vitest"; import { stripAnsi } from "../../tests/helpers/ansi.ts"; import { DbExecError } from "./db-connection.errors.ts"; @@ -87,39 +87,46 @@ describe("reconcileMigrations", () => { }); describe("listRemoteMigrations (suppress only undefined_table, like Go)", () => { - const run = (error: DbExecError) => - Effect.runPromiseExit(listRemoteMigrations(failingSession(error))); - - it("treats a missing history table (42P01) as an empty history", async () => { - const exit = await run( - new DbExecError({ - message: 'relation "supabase_migrations.schema_migrations" does not exist', - code: "42P01", - }), - ); - expect(exit).toStrictEqual(Exit.succeed([])); - }); - - it("propagates a malformed table (undefined column 42703) instead of swallowing it", async () => { - const exit = await run( - new DbExecError({ message: 'column "version" does not exist', code: "42703" }), - ); - expect(Exit.isFailure(exit)).toBe(true); - }); - - it("falls back to a relation-not-exist message when no SQLSTATE is surfaced", async () => { - const exit = await run( - new DbExecError({ - message: 'relation "supabase_migrations.schema_migrations" does not exist', - }), - ); - expect(exit).toStrictEqual(Exit.succeed([])); - }); - - it("does not swallow a column-not-exist message when no SQLSTATE is surfaced", async () => { - const exit = await run(new DbExecError({ message: 'column "version" does not exist' })); - expect(Exit.isFailure(exit)).toBe(true); - }); + const run = (error: DbExecError) => listRemoteMigrations(failingSession(error)).pipe(Effect.exit); + + it.effect("treats a missing history table (42P01) as an empty history", () => + Effect.gen(function* () { + const exit = yield* run( + new DbExecError({ + message: 'relation "supabase_migrations.schema_migrations" does not exist', + code: "42P01", + }), + ); + expect(exit).toStrictEqual(Exit.succeed([])); + }), + ); + + it.effect("propagates a malformed table (undefined column 42703) instead of swallowing it", () => + Effect.gen(function* () { + const exit = yield* run( + new DbExecError({ message: 'column "version" does not exist', code: "42703" }), + ); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); + + it.effect("falls back to a relation-not-exist message when no SQLSTATE is surfaced", () => + Effect.gen(function* () { + const exit = yield* run( + new DbExecError({ + message: 'relation "supabase_migrations.schema_migrations" does not exist', + }), + ); + expect(exit).toStrictEqual(Exit.succeed([])); + }), + ); + + it.effect("does not swallow a column-not-exist message when no SQLSTATE is surfaced", () => + Effect.gen(function* () { + const exit = yield* run(new DbExecError({ message: 'column "version" does not exist' })); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); }); describe("findPendingMigrations (Go TestPendingMigrations / TestIgnoreVersionMismatch)", () => { @@ -186,7 +193,7 @@ describe("suggestRevertHistory", () => { }); describe("resolveMigrationFile (byte-ordered match, Go's sort.Strings via afero match.go:91)", () => { - it("picks the UTF-8-byte-first match, not JS's default UTF-16 code-unit order", async () => { + it.effect("picks the UTF-8-byte-first match, not JS's default UTF-16 code-unit order", () => { // A supplementary-plane character (U+1F600, a UTF-16 surrogate pair) alongside a BMP // private-use character (U+E000): JS's default `.sort()` ranks the surrogate pair first — // its leading high-surrogate code unit (0xD83D) is less than the private-use code unit @@ -205,16 +212,19 @@ describe("resolveMigrationFile (byte-ordered match, Go's sort.Strings via afero ), Path.layer, ); - const result = await Effect.runPromise( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* resolveMigrationFile(fs, path, "/supabase/migrations", "20240101000000"); - }).pipe(Effect.provide(layer)), - ); - expect(Option.isSome(result) ? result.value : undefined).toBe( - `/supabase/migrations/${privateUse}`, - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* resolveMigrationFile( + fs, + path, + "/supabase/migrations", + "20240101000000", + ); + expect(Option.isSome(result) ? result.value : undefined).toBe( + `/supabase/migrations/${privateUse}`, + ); + }).pipe(Effect.provide(layer)); }); }); @@ -249,49 +259,57 @@ describe("createMigrationTable / createSeedTable (provisioning probe, #6393)", ( return { session, execs, queries }; }; - it("puts no DDL on the wire when a ledger is already provisioned", async () => { - const { session, execs, queries } = probedSession([{ provisioned: true }]); - await Effect.runPromise(createMigrationTable(session)); - await Effect.runPromise(createSeedTable(session)); - expect(execs).toEqual([]); - expect(queries).toHaveLength(2); - expect(queries[0]?.sql).toContain("'supabase_migrations.schema_migrations'"); - expect(queries[1]?.sql).toContain("'supabase_migrations.seed_files'"); - }); - - it("keeps the probes on the simple query protocol: no bind parameters", async () => { - const { session, queries } = probedSession([{ provisioned: true }]); - await Effect.runPromise(createMigrationTable(session)); - await Effect.runPromise(createSeedTable(session)); - expect(queries).toHaveLength(2); - expect(queries.filter((q) => q.params !== undefined)).toEqual([]); - expect(queries.filter((q) => q.sql.includes("$"))).toEqual([]); - }); + it.effect("puts no DDL on the wire when a ledger is already provisioned", () => + Effect.gen(function* () { + const { session, execs, queries } = probedSession([{ provisioned: true }]); + yield* createMigrationTable(session); + yield* createSeedTable(session); + expect(execs).toEqual([]); + expect(queries).toHaveLength(2); + expect(queries[0]?.sql).toContain("'supabase_migrations.schema_migrations'"); + expect(queries[1]?.sql).toContain("'supabase_migrations.seed_files'"); + }), + ); + + it.effect("keeps the probes on the simple query protocol: no bind parameters", () => + Effect.gen(function* () { + const { session, queries } = probedSession([{ provisioned: true }]); + yield* createMigrationTable(session); + yield* createSeedTable(session); + expect(queries).toHaveLength(2); + expect(queries.filter((q) => q.params !== undefined)).toEqual([]); + expect(queries.filter((q) => q.sql.includes("$"))).toEqual([]); + }), + ); for (const [shape, rows] of [ ["an absent ledger (no rows)", []], ["a partial ledger (provisioned: false)", [{ provisioned: false }]], ["an unexpected result shape", [{ wat: 1 }]], ] as const) { - it(`runs the full provisioning DDL in order against ${shape}`, async () => { - const { session, execs } = probedSession([...rows]); - await Effect.runPromise(createMigrationTable(session)); - expect(execs).toEqual(HISTORY_DDL); - }); + it.effect(`runs the full provisioning DDL in order against ${shape}`, () => + Effect.gen(function* () { + const { session, execs } = probedSession([...rows]); + yield* createMigrationTable(session); + expect(execs).toEqual(HISTORY_DDL); + }), + ); } - it("propagates a probe failure without opening a transaction to roll back", async () => { - const error = new DbExecError({ message: "effect/sql/SqlError: Connection error" }); - const execs: Array = []; - const session: DbSession = { - ...failingSession(error), - exec: (sql) => { - execs.push(sql); - return Effect.void; - }, - }; - expect(await Effect.runPromise(Effect.flip(createMigrationTable(session)))).toBe(error); - expect(await Effect.runPromise(Effect.flip(createSeedTable(session)))).toBe(error); - expect(execs).toEqual([]); - }); + it.effect("propagates a probe failure without opening a transaction to roll back", () => + Effect.gen(function* () { + const error = new DbExecError({ message: "effect/sql/SqlError: Connection error" }); + const execs: Array = []; + const session: DbSession = { + ...failingSession(error), + exec: (sql) => { + execs.push(sql); + return Effect.void; + }, + }; + expect(yield* Effect.flip(createMigrationTable(session))).toBe(error); + expect(yield* Effect.flip(createSeedTable(session))).toBe(error); + expect(execs).toEqual([]); + }), + ); }); diff --git a/apps/cli/src/command-internal/migration-list.unit.test.ts b/apps/cli/src/command-internal/migration-list.unit.test.ts index 640139adc5..b0f155b863 100644 --- a/apps/cli/src/command-internal/migration-list.unit.test.ts +++ b/apps/cli/src/command-internal/migration-list.unit.test.ts @@ -1,152 +1,118 @@ -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { expect, layer } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Path } from "effect"; -import { Output } from "../shared/output/output.service.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import { listLocalMigrations } from "./migration-list.ts"; -const withTemp = () => mkdtempSync(join(tmpdir(), "migration-list-")); +const project = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "migration-list-" }); + return { fs, path, root, migrationsDir: path.join(root, "supabase", "migrations") }; +}); -const run = (effect: Effect.Effect) => - effect.pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer)), - ) as Effect.Effect; +const list = (migrationsDir: string, out = mockOutput()) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* listLocalMigrations(fs, path, migrationsDir); + }).pipe(Effect.provide(out.layer)); -const withServices = ( - body: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, -) => - run( +layer(BunServices.layer)("listLocalMigrations", (it) => { + it.effect("returns sorted valid migrations, skipping a deprecated _init.sql first file", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* body(fs, path); + const { fs, path, migrationsDir } = yield* project; + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString(path.join(migrationsDir, "20200101000000_init.sql"), "-- old init"); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101120000_create.sql"), + "create table x();", + ); + yield* fs.writeFileString(path.join(migrationsDir, "notes.txt"), "ignore me"); + const paths = yield* list(migrationsDir); + expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); }), ); -describe("listLocalMigrations", () => { - it.effect("returns sorted valid migrations, skipping a deprecated _init.sql first file", () => { - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); - return withServices((fs, path) => listLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - it.effect( "warns (byte-exact, on stderr) when skipping a deprecated init and a misnamed file", - () => { - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* listLocalMigrations(fs, path, migrationsDir); - }).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, out.layer)), - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); - const stderr = out.rawChunks.filter((c) => c.stream === "stderr").map((c) => c.text); - expect(stderr).toContain( - 'Skipping migration 20200101000000_init.sql... (replace "init" with a different file name to apply this migration)\n', - ); - expect(stderr).toContain( - 'Skipping migration notes.txt... (file name must match pattern "_name.sql")\n', - ); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ) as Effect.Effect; - }, + () => + Effect.gen(function* () { + const { fs, path, migrationsDir } = yield* project; + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20200101000000_init.sql"), + "-- old init", + ); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101120000_create.sql"), + "create table x();", + ); + yield* fs.writeFileString(path.join(migrationsDir, "notes.txt"), "ignore me"); + const out = mockOutput(); + const paths = yield* list(migrationsDir, out); + expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); + const stderr = out.rawChunks.filter((c) => c.stream === "stderr").map((c) => c.text); + expect(stderr).toContain( + 'Skipping migration 20200101000000_init.sql... (replace "init" with a different file name to apply this migration)\n', + ); + expect(stderr).toContain( + 'Skipping migration notes.txt... (file name must match pattern "_name.sql")\n', + ); + }), ); - it.effect("includes a validly-named .sql symlink to a directory (no symlink follow)", () => { - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const targetDir = join(dir, "outside-target"); - mkdirSync(targetDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - symlinkSync(targetDir, join(migrationsDir, "20240102000000_link.sql")); - return withServices((fs, path) => listLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual([ - "20240101120000_create.sql", - "20240102000000_link.sql", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("includes a validly-named .sql symlink to a directory (no symlink follow)", () => + Effect.gen(function* () { + const { fs, path, root, migrationsDir } = yield* project; + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + const targetDir = path.join(root, "outside-target"); + yield* fs.makeDirectory(targetDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101120000_create.sql"), + "create table x();", + ); + yield* fs.symlink(targetDir, path.join(migrationsDir, "20240102000000_link.sql")); + const paths = yield* list(migrationsDir); + expect(paths.map((p) => p.split("/").pop())).toEqual([ + "20240101120000_create.sql", + "20240102000000_link.sql", + ]); + }), + ); - it.effect("sorts by UTF-8 byte order, not JS's default UTF-16 code-unit order", () => { - // U+E000 (BMP private-use, single UTF-16 code unit 0xE000) and U+1F600 (supplementary-plane, - // a surrogate pair starting 0xD83D) sort in opposite order under UTF-16 code units vs. UTF-8 - // bytes: JS's default `.sort()` ranks the surrogate pair first (0xD83D < 0xE000), while byte - // order (which preserves codepoint order) ranks U+1F600 after U+E000. - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const privateUseFile = "20240101120000_z\uE000.sql"; - const supplementaryFile = "20240101120000_z\u{1F600}.sql"; - writeFileSync(join(migrationsDir, privateUseFile), "create table x();"); - writeFileSync(join(migrationsDir, supplementaryFile), "create table y();"); - return withServices((fs, path) => listLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual([privateUseFile, supplementaryFile]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("sorts by UTF-8 byte order, not JS's default UTF-16 code-unit order", () => + Effect.gen(function* () { + // U+E000 (BMP private-use, single UTF-16 code unit 0xE000) and U+1F600 (supplementary-plane, + // a surrogate pair starting 0xD83D) sort in opposite order under UTF-16 code units vs. UTF-8 + // bytes: JS's default `.sort()` ranks the surrogate pair first (0xD83D < 0xE000), while byte + // order (which preserves codepoint order) ranks U+1F600 after U+E000. + const { fs, path, migrationsDir } = yield* project; + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + const privateUseFile = "20240101120000_z\uE000.sql"; + const supplementaryFile = "20240101120000_z\u{1F600}.sql"; + yield* fs.writeFileString(path.join(migrationsDir, privateUseFile), "create table x();"); + yield* fs.writeFileString(path.join(migrationsDir, supplementaryFile), "create table y();"); + const paths = yield* list(migrationsDir); + expect(paths.map((p) => p.split("/").pop())).toEqual([privateUseFile, supplementaryFile]); + }), + ); - it.effect("returns [] when the migrations dir is absent", () => { - const dir = withTemp(); - return withServices((fs, path) => listLocalMigrations(fs, path, join(dir, "nope"))).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("returns [] when the migrations dir is absent", () => + Effect.gen(function* () { + const { path, root } = yield* project; + expect(yield* list(path.join(root, "nope"))).toEqual([]); + }), + ); - it.effect("fails (instead of returning []) when the migrations path is unreadable", () => { - const dir = withTemp(); - const migrationsPath = join(dir, "supabase", "migrations"); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(migrationsPath, "not a directory"); - return withServices((fs, path) => - listLocalMigrations(fs, path, migrationsPath).pipe(Effect.exit), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("fails (instead of returning []) when the migrations path is unreadable", () => + Effect.gen(function* () { + const { fs, path, root, migrationsDir } = yield* project; + yield* fs.makeDirectory(path.join(root, "supabase"), { recursive: true }); + yield* fs.writeFileString(migrationsDir, "not a directory"); + const exit = yield* list(migrationsDir).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); }); diff --git a/apps/cli/src/command-internal/migration-timestamp.format.ts b/apps/cli/src/command-internal/migration-timestamp.format.ts index 9ab1f95010..8167f43ce7 100644 --- a/apps/cli/src/command-internal/migration-timestamp.format.ts +++ b/apps/cli/src/command-internal/migration-timestamp.format.ts @@ -1,3 +1,5 @@ +import { DateTime } from "effect"; + /** * Parses a `YYYYMMDDHHMMSS` migration version and reformats it as `YYYY-MM-DD HH:MM:SS`. * Returns the input unchanged when it isn't a valid timestamp (non-numeric, wrong length, @@ -24,12 +26,10 @@ export function formatTimestampVersion(version: string): string { if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) { return version; } - const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); - if ( - date.getUTCFullYear() !== year || - date.getUTCMonth() !== month - 1 || - date.getUTCDate() !== day - ) { + const parts = DateTime.toPartsUtc( + DateTime.makeUnsafe(Date.UTC(year, month - 1, day, hour, minute, second)), + ); + if (parts.year !== year || parts.month !== month || parts.day !== day) { return version; } return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`; diff --git a/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts b/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts index 76f8b6be16..c3e1d2114b 100644 --- a/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts +++ b/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts @@ -29,6 +29,11 @@ describe("formatTimestampVersion", () => { it("passes through versions that are not exactly 14 digits", () => { expect(formatTimestampVersion("202207270642460")).toBe("202207270642460"); // 15 digits }); + + it("passes through years below 100, unlike Go's time.Parse", () => { + expect(formatTimestampVersion("00991231000000")).toBe("00991231000000"); + expect(formatTimestampVersion("01000101000000")).toBe("0100-01-01 00:00:00"); + }); }); describe("parseMigrationVersion", () => { diff --git a/apps/cli/src/command-internal/pg-dump.run.unit.test.ts b/apps/cli/src/command-internal/pg-dump.run.unit.test.ts index ee65560c58..967e83658a 100644 --- a/apps/cli/src/command-internal/pg-dump.run.unit.test.ts +++ b/apps/cli/src/command-internal/pg-dump.run.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { ConfigProvider, Effect, Layer, Option } from "effect"; import { DockerRun } from "./docker-run.service.ts"; @@ -8,26 +8,26 @@ import { streamPgDump } from "./pg-dump.run.ts"; import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts"; describe("streamPgDump", () => { - it("maps registry configuration failures to DockerRunError", async () => { - let runStreamCalls = 0; - const dockerLayer = Layer.succeed(DockerRun, { - run: () => Effect.succeed(0), - runCapture: () => Effect.succeed({ exitCode: 0, stdout: new Uint8Array(), stderr: "" }), - runStream: () => { - runStreamCalls += 1; - return Effect.succeed({ exitCode: 0, stderr: "" }); - }, - }); - const configProvider = ConfigProvider.make((path) => - path[0] === "SUPABASE_INTERNAL_IMAGE_REGISTRY" - ? Effect.fail( - new ConfigProvider.SourceError({ message: "injected registry config failure" }), - ) - : Effect.succeed(undefined), - ); + it.effect("maps registry configuration failures to DockerRunError", () => + Effect.gen(function* () { + let runStreamCalls = 0; + const dockerLayer = Layer.succeed(DockerRun, { + run: () => Effect.succeed(0), + runCapture: () => Effect.succeed({ exitCode: 0, stdout: new Uint8Array(), stderr: "" }), + runStream: () => { + runStreamCalls += 1; + return Effect.succeed({ exitCode: 0, stderr: "" }); + }, + }); + const configProvider = ConfigProvider.make((path) => + path[0] === "SUPABASE_INTERNAL_IMAGE_REGISTRY" + ? Effect.fail( + new ConfigProvider.SourceError({ message: "injected registry config failure" }), + ) + : Effect.undefined, + ); - const error = await Effect.runPromise( - streamPgDump({ + const error = yield* streamPgDump({ image: "supabase/postgres:17", script: "select 1", env: {}, @@ -42,11 +42,11 @@ describe("streamPgDump", () => { ConfigProvider.layer(configProvider), ), ), - ), - ); + ); - expect(error).toBeInstanceOf(DockerRunError); - expect(error).toMatchObject({ reason: "config", daemonDown: false }); - expect(runStreamCalls).toBe(0); - }); + expect(error).toBeInstanceOf(DockerRunError); + expect(error).toMatchObject({ reason: "config", daemonDown: false }); + expect(runStreamCalls).toBe(0); + }), + ); }); diff --git a/apps/cli/src/command-internal/seed-ops.unit.test.ts b/apps/cli/src/command-internal/seed-ops.unit.test.ts index 85c91b5e5e..6fc7a4859a 100644 --- a/apps/cli/src/command-internal/seed-ops.unit.test.ts +++ b/apps/cli/src/command-internal/seed-ops.unit.test.ts @@ -1,10 +1,8 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; +import { expect, layer } from "@effect/vitest"; import { Data, Effect, Exit, FileSystem, Path } from "effect"; +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import type { DbSession } from "./db-connection.service.ts"; import { getPendingSeeds, seedData } from "./seed-ops.ts"; @@ -34,29 +32,30 @@ function fakeSeedSession(opts: { restoreRoleSql?: string } = {}) { return { session, calls }; } +const tempDir = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix }); + return { fs, path, dir }; + }); + // Exercises that `getPendingSeeds`'s glob resolution actually uses `pathMatch` // (`../../../shared/path-match.ts`) end to end; pattern-matching semantics are covered by // `path-match.unit.test.ts`. -describe("getPendingSeeds (glob character classes)", () => { +layer(BunServices.layer)("getPendingSeeds (glob character classes)", (it) => { it.effect( "treats a leading `!` in a bracket class as literal, not negation (Go path.Match parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "seed-glob-")); - writeFileSync(join(dir, "a.sql"), "select 1;"); - writeFileSync(join(dir, "b.sql"), "select 2;"); - const { session } = fakeSeedSession(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-glob-"); + yield* fs.writeFileString(path.join(dir, "a.sql"), "select 1;"); + yield* fs.writeFileString(path.join(dir, "b.sql"), "select 2;"); + const { session } = fakeSeedSession(); // `[!a]` is a positive class of `!` and `a`; only a leading `^` negates. const pending = yield* getPendingSeeds(session, fs, path, ["[!a].sql"], dir); expect(pending.map((seed) => seed.path)).toEqual(["a.sql"]); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(BunServices.layer), - ); - }, + }).pipe(Effect.provide(mockOutput({ format: "text" }).layer)), ); it.effect( @@ -64,19 +63,16 @@ describe("getPendingSeeds (glob character classes)", () => { () => { // An unclosed `[` is malformed, so `fs.Glob` reports a syntax error, not a bogus // "no files matched" for a well-formed-but-empty pattern. - const dir = mkdtempSync(join(tmpdir(), "seed-glob-")); const { session } = fakeSeedSession(); const out = mockOutput({ format: "text" }); return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + const { fs, path, dir } = yield* tempDir("seed-glob-"); const pending = yield* getPendingSeeds(session, fs, path, ["seed[.sql"], dir); expect(pending).toEqual([]); expect(out.rawChunks.map((c) => c.text).join("")).toContain( "failed to glob files: syntax error in pattern", ); - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(out.layer), Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(out.layer)); }, ); }); @@ -97,104 +93,89 @@ const runSeed = ( seeds, (message) => new TestError({ message }), ); - }).pipe(Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(mockOutput({ format: "text" }).layer)); -describe("seedData (dirty parse)", () => { - it.effect("fails on an unreadable dirty seed instead of refreshing its hash", () => { - const dir = mkdtempSync(join(tmpdir(), "seed-")); - const { session, calls } = fakeSeedSession(); - return runSeed(session, dir, [{ path: "missing.sql", hash: "newhash", dirty: true }]).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - expect(calls.some((c) => c.kind === "query" && c.params !== undefined)).toBe(false); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); +layer(BunServices.layer)("seedData (dirty parse)", (it) => { + it.effect("fails on an unreadable dirty seed instead of refreshing its hash", () => + Effect.gen(function* () { + const { dir } = yield* tempDir("seed-"); + const { session, calls } = fakeSeedSession(); + const exit = yield* runSeed(session, dir, [ + { path: "missing.sql", hash: "newhash", dirty: true }, + ]).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(calls.some((c) => c.kind === "query" && c.params !== undefined)).toBe(false); + }), + ); it.effect( "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go SeedFile.ExecBatchWithCache parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "seed-scanner-")); - // The seed text must exceed the 4096-byte scanner floor regardless of the configured - // limit (see migration-apply.unit.test.ts's equivalent case). - writeFileSync(join(dir, "big.sql"), `select '${"x".repeat(5000)}';`); + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-scanner-"); + // The seed text must exceed the 4096-byte scanner floor regardless of the configured + // limit (see migration-apply.unit.test.ts's equivalent case). + yield* fs.writeFileString(path.join(dir, "big.sql"), `select '${"x".repeat(5000)}';`); + const { session, calls } = fakeSeedSession(); + const error = yield* runSeed(session, dir, [ + { path: "big.sql", hash: "newhash", dirty: false }, + ]).pipe(Effect.flip); + expect(error.message).toBe( + "bufio.Scanner: token too long\nAfter statement 0: \nTry setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 0KB)", + ); + expect(calls.some((c) => c.sql.includes("select"))).toBe(false); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), + ); + + it.effect("refreshes the hash for a dirty seed that parses, without running statements", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-"); + yield* fs.writeFileString(path.join(dir, "data.sql"), "insert into t values (1);"); const { session, calls } = fakeSeedSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; - return runSeed(session, dir, [{ path: "big.sql", hash: "newhash", dirty: false }]).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - expect(calls.some((c) => c.sql.includes("select"))).toBe(false); - rmSync(dir, { recursive: true, force: true }); - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - }), + yield* runSeed(session, dir, [{ path: "data.sql", hash: "newhash", dirty: true }]); + expect(calls.some((c) => c.sql === "SET LOCAL lock_timeout = '4s'")).toBe(true); + expect(calls.some((c) => c.sql.includes("insert into t"))).toBe(false); + expect( + calls.some( + (c) => c.kind === "query" && c.params !== undefined && c.sql.includes("seed_files"), ), - ); - }, + ).toBe(true); + }), ); - it.effect("refreshes the hash for a dirty seed that parses, without running statements", () => { - const dir = mkdtempSync(join(tmpdir(), "seed-")); - writeFileSync(join(dir, "data.sql"), "insert into t values (1);"); - const { session, calls } = fakeSeedSession(); - return runSeed(session, dir, [{ path: "data.sql", hash: "newhash", dirty: true }]).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(calls.some((c) => c.sql === "SET LOCAL lock_timeout = '4s'")).toBe(true); - expect(calls.some((c) => c.sql.includes("insert into t"))).toBe(false); - expect( - calls.some( - (c) => c.kind === "query" && c.params !== undefined && c.sql.includes("seed_files"), - ), - ).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect("re-asserts the stepped-down role before the seed_files upsert", () => { - const dir = mkdtempSync(join(tmpdir(), "seed-")); - writeFileSync(join(dir, "data.sql"), "set role r;\ninsert into t values (1);\nreset role;"); - const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); - return runSeed(session, dir, [{ path: "data.sql", hash: "h", dirty: false }]).pipe( - Effect.tap(() => - Effect.sync(() => { - const sqls = calls.map((c) => c.sql); - const restoreAt = sqls.indexOf("SET SESSION ROLE postgres"); - const upsertAt = calls.findIndex( - (c) => c.kind === "query" && c.params !== undefined && c.sql.includes("seed_files"), - ); - expect(restoreAt).toBeGreaterThan(sqls.indexOf("reset role")); - expect(upsertAt).toBeGreaterThan(restoreAt); - expect(sqls.lastIndexOf("COMMIT")).toBeGreaterThan(upsertAt); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("re-asserts the stepped-down role before the seed_files upsert", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-"); + yield* fs.writeFileString( + path.join(dir, "data.sql"), + "set role r;\ninsert into t values (1);\nreset role;", + ); + const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + yield* runSeed(session, dir, [{ path: "data.sql", hash: "h", dirty: false }]); + const sqls = calls.map((c) => c.sql); + const restoreAt = sqls.indexOf("SET SESSION ROLE postgres"); + const upsertAt = calls.findIndex( + (c) => c.kind === "query" && c.params !== undefined && c.sql.includes("seed_files"), + ); + expect(restoreAt).toBeGreaterThan(sqls.indexOf("reset role")); + expect(upsertAt).toBeGreaterThan(restoreAt); + expect(sqls.lastIndexOf("COMMIT")).toBeGreaterThan(upsertAt); + }), + ); - it.effect("restores the role right after a mid-seed reset, before later statements", () => { - const dir = mkdtempSync(join(tmpdir(), "seed-")); - writeFileSync(join(dir, "data.sql"), "set role r;\nreset role;\ninsert into t values (1);"); - const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); - return runSeed(session, dir, [{ path: "data.sql", hash: "h", dirty: false }]).pipe( - Effect.tap(() => - Effect.sync(() => { - const sqls = calls.map((c) => c.sql); - const resetAt = sqls.indexOf("reset role"); - expect(sqls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); - expect(sqls.indexOf("insert into t values (1)")).toBeGreaterThan(resetAt + 1); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("restores the role right after a mid-seed reset, before later statements", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-"); + yield* fs.writeFileString( + path.join(dir, "data.sql"), + "set role r;\nreset role;\ninsert into t values (1);", + ); + const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + yield* runSeed(session, dir, [{ path: "data.sql", hash: "h", dirty: false }]); + const sqls = calls.map((c) => c.sql); + const resetAt = sqls.indexOf("reset role"); + expect(sqls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); + expect(sqls.indexOf("insert into t values (1)")).toBeGreaterThan(resetAt + 1); + }), + ); }); diff --git a/apps/cli/src/command-internal/seed.unit.test.ts b/apps/cli/src/command-internal/seed.unit.test.ts index 1863e6919f..38c439bf5d 100644 --- a/apps/cli/src/command-internal/seed.unit.test.ts +++ b/apps/cli/src/command-internal/seed.unit.test.ts @@ -1,13 +1,11 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { expect, layer } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; import type { DbSession } from "./db-connection.service.ts"; -import { applySeedFiles } from "./seed.ts"; +import { applySeedFiles, MigrationSeedError } from "./seed.ts"; function fakeSession() { const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; @@ -26,6 +24,14 @@ function fakeSession() { return { session, queries }; } +const tempDir = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix }); + return { fs, path, dir }; + }); + const run = ( session: DbSession, workdir: string, @@ -36,140 +42,121 @@ const run = ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* applySeedFiles(session, fs, path, workdir, { enabled: true, sqlPaths }); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer))); + }).pipe(Effect.provide(out.layer)); -describe("applySeedFiles seed glob", () => { - it.effect("treats a backslash escape as a glob metacharacter (matches the real file)", () => { - const dir = mkdtempSync(join(tmpdir(), "seed-")); - writeFileSync(join(dir, "seed.sql"), "insert into t values (1);"); - const { session, queries } = fakeSession(); - const out = mockOutput(); - return run(session, dir, ["seed\\.sql"], out).pipe( - Effect.tap(() => - Effect.sync(() => { - const upsert = queries.find((q) => - q.sql.includes("INSERT INTO supabase_migrations.seed_files"), - ); - expect(upsert?.params?.[0]).toBe("seed.sql"); - expect(out.rawChunks.map((c) => c.text)).toContain("Seeding data from seed.sql...\n"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); +layer(BunServices.layer)("applySeedFiles seed glob", (it) => { + it.effect("treats a backslash escape as a glob metacharacter (matches the real file)", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-"); + yield* fs.writeFileString(path.join(dir, "seed.sql"), "insert into t values (1);"); + const { session, queries } = fakeSession(); + const out = mockOutput(); + yield* run(session, dir, ["seed\\.sql"], out); + const upsert = queries.find((q) => + q.sql.includes("INSERT INTO supabase_migrations.seed_files"), + ); + expect(upsert?.params?.[0]).toBe("seed.sql"); + expect(out.rawChunks.map((c) => c.text)).toContain("Seeding data from seed.sql...\n"); + }), + ); - it.effect("warns (no match) when a backslash-escaped pattern's literal file is absent", () => { - const dir = mkdtempSync(join(tmpdir(), "seed-")); - const { session, queries } = fakeSession(); - const out = mockOutput(); - return run(session, dir, ["missing\\.sql"], out).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(queries.some((q) => q.sql.includes("seed_files"))).toBe(false); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "warn", - message: "no files matched pattern: missing\\.sql", - }), - ); - rmSync(dir, { recursive: true, force: true }); + it.effect("warns (no match) when a backslash-escaped pattern's literal file is absent", () => + Effect.gen(function* () { + const { dir } = yield* tempDir("seed-"); + const { session, queries } = fakeSession(); + const out = mockOutput(); + yield* run(session, dir, ["missing\\.sql"], out); + expect(queries.some((q) => q.sql.includes("seed_files"))).toBe(false); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: "no files matched pattern: missing\\.sql", }), - ), - ); - }); + ); + }), + ); it.effect( "expands a matched directory to its sorted, regular .sql files (Go's Glob.SQLFiles)", - () => { - const dir = mkdtempSync(join(tmpdir(), "seed-")); - mkdirSync(join(dir, "seeds")); - writeFileSync(join(dir, "seeds", "b.sql"), "insert into t values (2);"); - writeFileSync(join(dir, "seeds", "a.sql"), "insert into t values (1);"); - writeFileSync(join(dir, "seeds", "README.md"), "not a seed file"); - const { session, queries } = fakeSession(); - const out = mockOutput(); - return run(session, dir, ["seeds"], out).pipe( - Effect.tap(() => - Effect.sync(() => { - const upserts = queries.filter((q) => - q.sql.includes("INSERT INTO supabase_migrations.seed_files"), - ); - expect(upserts.map((q) => q.params?.[0])).toEqual(["seeds/a.sql", "seeds/b.sql"]); - expect(out.rawChunks.map((c) => c.text)).toEqual([ - "Seeding data from seeds/a.sql...\n", - "Seeding data from seeds/b.sql...\n", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-"); + yield* fs.makeDirectory(path.join(dir, "seeds")); + yield* fs.writeFileString(path.join(dir, "seeds", "b.sql"), "insert into t values (2);"); + yield* fs.writeFileString(path.join(dir, "seeds", "a.sql"), "insert into t values (1);"); + yield* fs.writeFileString(path.join(dir, "seeds", "README.md"), "not a seed file"); + const { session, queries } = fakeSession(); + const out = mockOutput(); + yield* run(session, dir, ["seeds"], out); + const upserts = queries.filter((q) => + q.sql.includes("INSERT INTO supabase_migrations.seed_files"), + ); + expect(upserts.map((q) => q.params?.[0])).toEqual(["seeds/a.sql", "seeds/b.sql"]); + expect(out.rawChunks.map((c) => c.text)).toEqual([ + "Seeding data from seeds/a.sql...\n", + "Seeding data from seeds/b.sql...\n", + ]); + }), ); }); -describe("applySeedFiles scanner buffer size", () => { +layer(BunServices.layer)("applySeedFiles scanner buffer size", (it) => { it.effect( "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go SeedFile.ExecBatchWithCache parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "seed-scanner-")); - // The seed text must exceed the 4096-byte scanner floor regardless of the configured - // limit (see migration-apply.unit.test.ts's equivalent case). - writeFileSync(join(dir, "big.sql"), `insert into t values ('${"x".repeat(5000)}');`); - const { session, queries } = fakeSession(); - const out = mockOutput(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; - return run(session, dir, ["big.sql"], out).pipe( - Effect.exit, - Effect.tap((exit) => + () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-scanner-"); + // The seed text must exceed the 4096-byte scanner floor regardless of the configured + // limit (see migration-apply.unit.test.ts's equivalent case). + yield* fs.writeFileString( + path.join(dir, "big.sql"), + `insert into t values ('${"x".repeat(5000)}');`, + ); + const { session, queries } = fakeSession(); + const error = yield* run(session, dir, ["big.sql"], mockOutput()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationSeedError); + expect(error.message).toBe( + "bufio.Scanner: token too long\nAfter statement 0: \nTry setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 0KB)", + ); + expect(queries.some((q) => q.sql.includes("insert into t"))).toBe(false); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), + ); +}); + +layer(BunServices.layer)("applySeedFiles stepped-down session", (it) => { + it.effect("restores the role right after a reset and before the seed_files upsert", () => + Effect.gen(function* () { + const { fs, path, dir } = yield* tempDir("seed-"); + yield* fs.writeFileString( + path.join(dir, "seed.sql"), + "set role r;\nreset role;\ninsert into t values (1);", + ); + const calls: Array = []; + const session: DbSession = { + restoreRoleSql: "SET SESSION ROLE postgres", + exec: (sql) => Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - expect(queries.some((q) => q.sql.includes("insert into t"))).toBe(false); - rmSync(dir, { recursive: true, force: true }); - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + calls.push(sql); }), - ), + execBatch: () => Effect.void, + query: (sql) => + Effect.sync(() => { + calls.push(sql); + return []; + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + const out = mockOutput(); + yield* run(session, dir, ["seed.sql"], out); + const resetAt = calls.indexOf("reset role"); + const upsertAt = calls.findIndex((sql) => + sql.includes("INSERT INTO supabase_migrations.seed_files"), ); - }, + expect(calls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); + expect(upsertAt).toBeGreaterThan(resetAt); + expect(out.stderrText).not.toContain("WARN:"); + }), ); }); - -describe("applySeedFiles stepped-down session", () => { - it.effect("restores the role right after a reset and before the seed_files upsert", () => { - const dir = mkdtempSync(join(tmpdir(), "seed-")); - writeFileSync(join(dir, "seed.sql"), "set role r;\nreset role;\ninsert into t values (1);"); - const calls: Array = []; - const session: DbSession = { - restoreRoleSql: "SET SESSION ROLE postgres", - exec: (sql) => - Effect.sync(() => { - calls.push(sql); - }), - execBatch: () => Effect.void, - query: (sql) => - Effect.sync(() => { - calls.push(sql); - return []; - }), - extensionExists: () => Effect.succeed(false), - copyToCsv: () => Effect.succeed(new Uint8Array()), - queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - }; - const out = mockOutput(); - return run(session, dir, ["seed.sql"], out).pipe( - Effect.tap(() => - Effect.sync(() => { - const resetAt = calls.indexOf("reset role"); - const upsertAt = calls.findIndex((sql) => - sql.includes("INSERT INTO supabase_migrations.seed_files"), - ); - expect(calls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); - expect(upsertAt).toBeGreaterThan(resetAt); - expect(out.stderrText).not.toContain("WARN:"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); -}); From 02f0908acf0e21abd9f4818010abd97596ae32d6 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:33:46 +0000 Subject: [PATCH 3/4] refactor(cli): cover the `seed` and `pgdelta` modules with effect lint (CLI-2654) (#7118) ## TL;DR brings the `seed` and `pgdelta` modules under the effect lint ## whats introduced? - the `seed*`, `pgdelta*` and `diff-engine` files join the allow list - `isPgDeltaDebugEnabled` reads `PGDELTA_DEBUG` through `Config`, so `db diff`, `db pull` and the declarative `generate` and `sync` yield it - `seed-ops` fails the scanner check with `MigrationSeedError` instead of a bare `Error`, with the same message - `seed-buckets` yields its errors directly and recovers with `orElseSucceed` - the pgdelta tests pin `PGDELTA_DEBUG` through `withConfigEnv`, and the ssl probe test owns its server through `acquireRelease` ## ref - closes: CLI-2654 --- .oxlintrc.effect.json | 3 + apps/cli/src/command-internal/db-pull-run.ts | 5 +- .../pgdelta-ssl-probe.integration.test.ts | 100 +++++++++--------- apps/cli/src/command-internal/pgdelta.ts | 14 ++- .../src/command-internal/pgdelta.unit.test.ts | 47 ++++---- apps/cli/src/command-internal/seed-buckets.ts | 6 +- apps/cli/src/command-internal/seed-ops.ts | 3 +- apps/cli/src/commands/db/diff/diff.handler.ts | 4 +- .../commands/db/diff/diff.integration.test.ts | 13 +++ .../commands/db/pull/pull.integration.test.ts | 56 +++++----- .../declarative/generate/generate.handler.ts | 2 +- .../generate/generate.integration.test.ts | 13 +++ .../schema/declarative/sync/sync.handler.ts | 2 +- .../declarative/sync/sync.integration.test.ts | 17 ++- 14 files changed, 175 insertions(+), 110 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index f485a0b63e..46ed7b85b3 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -30,6 +30,7 @@ "!apps/cli/src/command-internal/db-*.ts", "!apps/cli/src/command-internal/debug-logger.*", "!apps/cli/src/command-internal/detect-content-type.*", + "!apps/cli/src/command-internal/diff-engine.*", "!apps/cli/src/command-internal/docker-*.ts", "!apps/cli/src/command-internal/dotenv.*", "!apps/cli/src/command-internal/drop-objects.ts", @@ -54,6 +55,7 @@ "!apps/cli/src/command-internal/parent-project-ref.*", "!apps/cli/src/command-internal/pg-dump.*", "!apps/cli/src/command-internal/pg-net-guidance.*", + "!apps/cli/src/command-internal/pgdelta*", "!apps/cli/src/command-internal/pgpass.*", "!apps/cli/src/command-internal/pgservicefile.*", "!apps/cli/src/command-internal/pooler-fallback.*", @@ -68,6 +70,7 @@ "!apps/cli/src/command-internal/ref-patterns.*", "!apps/cli/src/command-internal/resolve-token.*", "!apps/cli/src/command-internal/schema-flags.*", + "!apps/cli/src/command-internal/seed*", "!apps/cli/src/command-internal/sql-split.*", "!apps/cli/src/command-internal/stack-api.ts", "!apps/cli/src/command-internal/stack-backend.ts", diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 1f92838e7c..5cfa421d89 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -392,6 +392,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( yield* output.raw("Preparing declarative schema export using pg-delta...\n", "stderr"); const declarativeDirRel = resolveDeclarativeDir(path, toml.pgDelta); const declarativeDir = path.resolve(cliSettings.workdir, declarativeDirRel); + const pgDeltaDebug = yield* isPgDeltaDebugEnabled; const exportSchema = (target: PgDeltaDatabaseEndpoint) => pgDeltaEngine.exportDeclarativeSchema({ context: ctx, @@ -401,7 +402,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( ...(connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : {}), - debug: isPgDeltaDebugEnabled(), + debug: pgDeltaDebug, strictCoverage: flags.strictCoverage, }); const exported = yield* withPoolerFallback(targetEndpoint, (target) => @@ -657,7 +658,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }, schema: diffSchema, formatOptions, - debug: isPgDeltaDebugEnabled(), + debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, }); } diff --git a/apps/cli/src/command-internal/pgdelta-ssl-probe.integration.test.ts b/apps/cli/src/command-internal/pgdelta-ssl-probe.integration.test.ts index 56eafc828a..11dc693b46 100644 --- a/apps/cli/src/command-internal/pgdelta-ssl-probe.integration.test.ts +++ b/apps/cli/src/command-internal/pgdelta-ssl-probe.integration.test.ts @@ -1,63 +1,61 @@ -import { createServer } from "node:net"; +import { createServer, type Server } from "node:net"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Data, Effect, Exit } from "effect"; -import { DebugFlag } from "./global-flags.ts"; import { pgDeltaSslProbeLayer } from "./pgdelta-ssl-probe.layer.ts"; import { PgDeltaSslProbe, PgDeltaSslProbeError } from "./pgdelta-ssl-probe.service.ts"; -async function withClosingServer(run: (port: number) => Promise): Promise { - const server = createServer((socket) => { - socket.destroy(); - }); +class FixtureError extends Data.TaggedError("FixtureError")<{ + readonly message: string; +}> {} - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - - const address = server.address(); - if (address === null || typeof address === "string") { - server.close(); - throw new Error("failed to bind closing server"); - } - - try { - return await run(address.port); - } finally { - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } -} +const closingServer = Effect.acquireRelease( + Effect.callback<{ readonly server: Server; readonly port: number }, FixtureError>((resume) => { + const server = createServer((socket) => { + socket.destroy(); + }); + const onError = (cause: Error) => + resume(Effect.fail(new FixtureError({ message: cause.message }))); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const address = server.address(); + if (address === null || typeof address === "string") { + server.close(); + resume(Effect.fail(new FixtureError({ message: "failed to bind closing server" }))); + } else { + resume(Effect.succeed({ server, port: address.port })); + } + }); + return Effect.sync(() => { + server.off("error", onError); + server.close(); + }); + }), + ({ server }) => + Effect.callback((resume) => { + server.close((error) => resume(error ? Effect.die(error) : Effect.void)); + }), +); describe("pgDeltaSslProbeLayer", () => { it.live("fails promptly when the server disconnects before an SSL response byte", () => - Effect.tryPromise({ - try: () => - withClosingServer((port) => - Effect.runPromise( - Effect.gen(function* () { - const probe = yield* PgDeltaSslProbe; - const exit = yield* probe.requireSslForHost("127.0.0.1", port).pipe( - Effect.timeoutOrElse({ - duration: "1 second", - orElse: () => Effect.fail(new Error("probe did not settle after socket close")), - }), - Effect.exit, - ); + Effect.gen(function* () { + const { port } = yield* closingServer; + const probe = yield* PgDeltaSslProbe; + const exit = yield* probe.requireSslForHost("127.0.0.1", port).pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => + Effect.fail(new FixtureError({ message: "probe did not settle after socket close" })), + }), + Effect.exit, + ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain(PgDeltaSslProbeError.name); - } - }).pipe( - Effect.provide(pgDeltaSslProbeLayer), - Effect.provide(Layer.succeed(DebugFlag, false)), - ), - ), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain(PgDeltaSslProbeError.name); + } + }).pipe(Effect.provide(pgDeltaSslProbeLayer)), ); }); diff --git a/apps/cli/src/command-internal/pgdelta.ts b/apps/cli/src/command-internal/pgdelta.ts index 92b7c03448..b00322df8f 100644 --- a/apps/cli/src/command-internal/pgdelta.ts +++ b/apps/cli/src/command-internal/pgdelta.ts @@ -1,4 +1,4 @@ -import { Option, type Path } from "effect"; +import { Config, Effect, Option, type Path } from "effect"; import { resolveLocalProjectId, sanitizeProjectId } from "./docker-ids.ts"; @@ -55,7 +55,11 @@ export function edgeRuntimeId(projectId: string): string { } /** Recognizes `PGDELTA_DEBUG=1`/`true`/`yes` (case-insensitive). */ -export function isPgDeltaDebugEnabled(): boolean { - const value = (process.env["PGDELTA_DEBUG"] ?? "").trim().toLowerCase(); - return value === "1" || value === "true" || value === "yes"; -} +export const isPgDeltaDebugEnabled = Config.String("PGDELTA_DEBUG").pipe( + Config.withDefault(""), + Config.map((raw) => { + const value = raw.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; + }), + Effect.orElseSucceed(() => false), +); diff --git a/apps/cli/src/command-internal/pgdelta.unit.test.ts b/apps/cli/src/command-internal/pgdelta.unit.test.ts index 7992e609c1..95fa4e2012 100644 --- a/apps/cli/src/command-internal/pgdelta.unit.test.ts +++ b/apps/cli/src/command-internal/pgdelta.unit.test.ts @@ -1,5 +1,7 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect } from "effect"; +import { withConfigEnv, withEmptyConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { edgeRuntimeId, isPgDeltaDebugEnabled, isPostgresURL } from "./pgdelta.ts"; describe("isPostgresURL", () => { @@ -18,23 +20,32 @@ describe("edgeRuntimeId", () => { }); describe("isPgDeltaDebugEnabled", () => { - const prev = process.env["PGDELTA_DEBUG"]; - afterEach(() => { - if (prev === undefined) delete process.env["PGDELTA_DEBUG"]; - else process.env["PGDELTA_DEBUG"] = prev; - }); + it.effect("is true for 1/true/yes (case-insensitive, trimmed)", () => + Effect.gen(function* () { + for (const value of ["1", "true", "YES", " True "]) { + expect(yield* withConfigEnv({ PGDELTA_DEBUG: value }, isPgDeltaDebugEnabled)).toBe(true); + } + }), + ); - it("is true for 1/true/yes (case-insensitive, trimmed)", () => { - for (const value of ["1", "true", "YES", " True "]) { - process.env["PGDELTA_DEBUG"] = value; - expect(isPgDeltaDebugEnabled()).toBe(true); - } - }); + it.effect("is false otherwise", () => + Effect.gen(function* () { + expect(yield* withConfigEnv({ PGDELTA_DEBUG: "0" }, isPgDeltaDebugEnabled)).toBe(false); + expect(yield* withEmptyConfigEnv(isPgDeltaDebugEnabled)).toBe(false); + }), + ); - it("is false otherwise", () => { - process.env["PGDELTA_DEBUG"] = "0"; - expect(isPgDeltaDebugEnabled()).toBe(false); - delete process.env["PGDELTA_DEBUG"]; - expect(isPgDeltaDebugEnabled()).toBe(false); - }); + it.effect("is false when PGDELTA_DEBUG cannot be resolved", () => + Effect.gen(function* () { + expect(yield* isPgDeltaDebugEnabled).toBe(false); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.make(() => + Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), + ), + ), + ), + ), + ); }); diff --git a/apps/cli/src/command-internal/seed-buckets.ts b/apps/cli/src/command-internal/seed-buckets.ts index 6ac1f2ed32..1a10b1df71 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -501,7 +501,7 @@ const handleVectorError = Effect.fnUntraced(function* ( summary.vector_skipped = true; return; } - return yield* Effect.fail(error); + return yield* error; }); const uploadObjects = Effect.fnUntraced(function* ( @@ -601,7 +601,7 @@ const collectDir = ( // has no `lstat`. const isSymlink = yield* fs.readLink(absChild).pipe( Effect.as(true), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ); if (isSymlink) { // Opens the target then stats the handle, so an unreadable target is caught now @@ -612,7 +612,7 @@ const collectDir = ( const targetInfo = yield* handle.stat; return targetInfo.type; }), - ).pipe(Effect.catch(() => Effect.succeed("Unknown" as const))); + ).pipe(Effect.orElseSucceed(() => "Unknown" as const)); if (targetType === "File") { collected.push({ absPath: absChild, displayPath: displayChild }); } else { diff --git a/apps/cli/src/command-internal/seed-ops.ts b/apps/cli/src/command-internal/seed-ops.ts index d865d86608..ba6e8ca636 100644 --- a/apps/cli/src/command-internal/seed-ops.ts +++ b/apps/cli/src/command-internal/seed-ops.ts @@ -6,6 +6,7 @@ import type { DbExecError } from "./db-connection.errors.ts"; import type { DbSession } from "./db-connection.service.ts"; import { checkScannerBufferSize, revertsToLoginRole } from "./migration-apply.ts"; import { createSeedTable } from "./migration-history.ts"; +import { MigrationSeedError } from "./seed.ts"; import { sqlFilesGlob } from "./sql-files-glob.ts"; import { splitAndTrim } from "./sql-split.ts"; @@ -117,7 +118,7 @@ export const seedData = ( const content = yield* fs.readFileString( path.isAbsolute(seed.path) ? seed.path : path.join(workdir, seed.path), ); - yield* checkScannerBufferSize(content, (message) => new Error(message)); + yield* checkScannerBufferSize(content, (message) => new MigrationSeedError({ message })); const lines = splitAndTrim(content); const statements = seed.dirty ? [] : lines; yield* session.exec("BEGIN"); diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 004b2beea9..56b154a9ee 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -370,7 +370,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { desired, schema: flags.schema, formatOptions: Option.getOrElse(cfg.pgDelta.formatOptions, () => ""), - debug: isPgDeltaDebugEnabled(), + debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, }); // Explicit-mode output: `--output` file, or stdout with no trailing newline @@ -704,7 +704,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }, schema: flags.schema, formatOptions, - debug: isPgDeltaDebugEnabled(), + debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, }); return { sql: result.sql, files: result.files, hazards: result.hazards }; diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 4e033b6e98..120ca9ac83 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -671,6 +671,19 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect.each([ + { value: "1", debug: true }, + { value: "", debug: false }, + ])('passes PGDELTA_DEBUG="$value" to both pg-delta diff modes', ({ value, debug }) => { + const s = setup(tmp.current, { diffSql: "create table p ();\n" }); + return Effect.gen(function* () { + yield* dbDiff(flags({ usePgDelta: Option.some(true) })); + yield* dbDiff(flags({ from: Option.some("local"), to: Option.some("local") })); + expect(s.databaseDiffCalls.map((call) => call.debug)).toEqual([debug]); + expect(s.explicitDiffCalls.map((call) => call.debug)).toEqual([debug]); + }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ PGDELTA_DEBUG: value }, body)); + }); + it.effect("pg-delta local diff ignores schema_paths and declarative files", () => { const s = setup(tmp.current, { files: { diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index b0be6b3e14..563b6d5add 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -3,7 +3,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, - Config, ConfigProvider, Effect, Exit, @@ -165,6 +164,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { projectRef?: string; projectId: string; strictCoverage: boolean; + debug: boolean; }> = []; let engineDiffCount = 0; const pgDeltaEngine = Layer.succeed( @@ -178,6 +178,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { projectRef: input.projectRef, projectId: input.context.projectId, strictCoverage: input.strictCoverage, + debug: input.debug, }); engineDiffCount += 1; if (opts.edgeFailFirstWith !== undefined && engineDiffCount === 1) { @@ -190,26 +191,19 @@ function setup(workdir: string, opts: SetupOpts = {}) { } const stdout = opts.edgeStdout ?? ""; if (stdout.trim().length === 0) { - return Config.option(Config.String("PGDELTA_DEBUG")).pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnv({ preserveEmptyStrings: true }), - ), - Effect.mapError((cause) => new PgDeltaEngineError({ message: cause.message, cause })), - Effect.map((pgDeltaDebug) => ({ - changes: false, - sql: "", - files: [], - ...(Option.isSome(pgDeltaDebug) - ? { - debug: - opts.nextDebugDirectory !== undefined - ? { directory: opts.nextDebugDirectory } - : {}, - } - : {}), - })), - ); + return Effect.succeed({ + changes: false, + sql: "", + files: [], + ...(input.debug + ? { + debug: + opts.nextDebugDirectory !== undefined + ? { directory: opts.nextDebugDirectory } + : {}, + } + : {}), + }); } try { const parsed: unknown = JSON.parse(stdout); @@ -255,6 +249,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { projectRef: input.projectRef, projectId: input.context.projectId, strictCoverage: input.strictCoverage, + debug: input.debug, }); if (opts.edgeFailFirstWith !== undefined && engineCalls.length === 1) { return Effect.fail( @@ -977,6 +972,17 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect.each([ + { value: "1", debug: true }, + { value: "", debug: false }, + ])('declarative export passes PGDELTA_DEBUG="$value" to pg-delta', ({ value, debug }) => { + const s = setup(tmp.current, {}); + return Effect.gen(function* () { + yield* dbPull(flags({ declarative: Option.some(true) })); + expect(s.engineCalls.map((call) => call.debug)).toEqual([debug]); + }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ PGDELTA_DEBUG: value }, body)); + }); + it.effect( "pull --declarative writes [db.migrations] schema_paths when pg-delta is disabled", () => { @@ -1419,11 +1425,12 @@ describe("db pull", () => { return Effect.gen(function* () { const error = yield* dbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe(Effect.flip); expect(error.message).toBe("No schema changes found"); + expect(s.engineCalls.map((call) => call.debug)).toEqual([false]); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const debugRoot = path.join(tmp.current, "supabase", ".temp", "pgdelta", "debug"); expect((yield* fs.exists(debugRoot)) ? yield* fs.readDirectory(debugRoot) : []).toEqual([]); - }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(s.layer), withEmptyConfigEnv); }); it.effect("reports the next-generation debug directory for an empty pg-delta diff", () => @@ -1444,9 +1451,8 @@ describe("db pull", () => { edgeStdout: "", nextDebugDirectory: debugDir, }); - const error = yield* withEnvVar( - "PGDELTA_DEBUG", - "1", + const error = yield* withConfigEnv( + { PGDELTA_DEBUG: "1" }, dbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( Effect.flip, Effect.provide(s.layer), diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts index 798400dd1a..fbe93b4d58 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts @@ -154,7 +154,7 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene declarativeDirDisplay: declarativeDirRel, schema: flags.schema, noCache: flags.noCache, - debug: isPgDeltaDebugEnabled(), + debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, dnsResolver, ...(linkedProjectRef !== undefined ? { linkedProjectRef } : {}), diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 1cab58e19a..3d12ded7ab 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -93,6 +93,7 @@ interface EngineExportCall { readonly targetRef: string; readonly projectRef: string | undefined; readonly strictCoverage: boolean; + readonly debug: boolean; } const GENERATE_STACK_ID = "e".repeat(64); @@ -259,6 +260,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { targetRef: input.target.ref, projectRef: input.projectRef, strictCoverage: input.strictCoverage, + debug: input.debug, }); return opts.exportFails === true ? Effect.fail( @@ -968,6 +970,17 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect.each([ + { value: "1", debug: true }, + { value: "", debug: false }, + ])('passes PGDELTA_DEBUG="$value" through to the engine export', ({ value, debug }) => { + const s = setup(tmp.current, { experimental: true }); + return Effect.gen(function* () { + yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + expect(s.engineExportCalls.map((call) => call.debug)).toEqual([debug]); + }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ PGDELTA_DEBUG: value }, body)); + }); + it.effect("fails generate when the engine export fails", () => { const s = setup(tmp.current, { experimental: true, exportFails: true }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index 5a6c64cefc..e4caa00467 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -157,7 +157,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f declarativeDirDisplay: declarativeDirRel, schema: flags.schema, noCache: flags.noCache, - debug: isPgDeltaDebugEnabled(), + debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, dnsResolver, }; diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 8bfaac3765..5def629575 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -303,6 +303,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const nextFiles = opts.renderedFiles ?? []; const planErrors = [...(opts.planErrors ?? [])]; let planCalls = 0; + const planDebug: Array = []; const declarativeExportCalls: Array> = []; const engine = Layer.succeed( PgDeltaEngine, @@ -317,9 +318,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, }; }), - planDeclarativeSchema: () => + planDeclarativeSchema: (input) => Effect.gen(function* () { planCalls += 1; + planDebug.push(input.debug); const planError = planErrors.shift(); if (planError !== undefined) return yield* planError; const fs = yield* FileSystem.FileSystem; @@ -403,6 +405,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, localPostgresImageChecks, declarativeExportCalls, + planDebug, get planCalls() { return planCalls; }, @@ -948,6 +951,18 @@ describe("db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect.each([ + { value: "1", debug: true }, + { value: "", debug: false }, + ])('passes PGDELTA_DEBUG="$value" through to the pg-delta planner', ({ value, debug }) => { + const s = setup(tmp.current, { experimental: true, diffSql: "" }); + return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); + yield* dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(s.planDebug).toEqual([debug]); + }).pipe(Effect.provide(s.layer), (body) => withConfigEnv({ PGDELTA_DEBUG: value }, body)); + }); + it.effect( "--no-apply: writes the timestamped migration, surfaces drop warnings, no apply", () => { From 2441fbfb6696f215b8eaa10dc0c68d5fdd22dede Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:52:28 +0000 Subject: [PATCH 4/4] chore(repo): format the develop-into-next merge --- .../migration-apply.unit.test.ts | 62 +++++++++---------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/apps/cli/src/command-internal/migration-apply.unit.test.ts b/apps/cli/src/command-internal/migration-apply.unit.test.ts index e6cbcb6da3..001178fdc4 100644 --- a/apps/cli/src/command-internal/migration-apply.unit.test.ts +++ b/apps/cli/src/command-internal/migration-apply.unit.test.ts @@ -1005,23 +1005,21 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { }), ); - it.effect( - "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is configured", - () => - Effect.gen(function* () { - // Exceeds the 4096-byte floor the scanner always starts at, regardless of the configured - // limit. - const { workdir } = yield* schemaFile( - "schema-files-scanner-", - "big.sql", - `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`, - ); - const { session } = fakeSession(); - const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); - expect(error.message).toContain("scanner: token too long"); - expect(error.message).toContain("After statement 1: SELECT 1;"); - expect(error.message).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB"); - }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), + it.effect("rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is configured", () => + Effect.gen(function* () { + // Exceeds the 4096-byte floor the scanner always starts at, regardless of the configured + // limit. + const { workdir } = yield* schemaFile( + "schema-files-scanner-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("scanner: token too long"); + expect(error.message).toContain("After statement 1: SELECT 1;"); + expect(error.message).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB"); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, body)), ); it.effect( @@ -1089,22 +1087,20 @@ layer(BunServices.layer)("applySchemaFiles", (it) => { }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "0x1400" }, body)), ); - it.effect( - "accepts underscore digit separators in a decimal SUPABASE_SCANNER_BUFFER_SIZE", - () => - Effect.gen(function* () { - const { workdir } = yield* schemaFile( - "schema-files-scanner-underscore-", - "big.sql", - `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`, - ); - const { session } = fakeSession(); - const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); - expect(error.message).toContain("scanner: token too long"); - expect(error.message).toContain( - "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", - ); - }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "5_120" }, body)), + it.effect("accepts underscore digit separators in a decimal SUPABASE_SCANNER_BUFFER_SIZE", () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-underscore-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`, + ); + const { session } = fakeSession(); + const error = yield* applySchema(session, workdir, "supabase/big.sql").pipe(Effect.flip); + expect(error.message).toContain("scanner: token too long"); + expect(error.message).toContain( + "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", + ); + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "5_120" }, body)), ); it.effect(