diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d81d2ada51..d95ac17c2c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -108,8 +108,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 @@ -122,11 +132,14 @@ jobs: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - 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 @@ -259,10 +272,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/.oxlintrc.effect.json b/.oxlintrc.effect.json index 3b04e5b03c..d694b8b8df 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -24,13 +24,16 @@ "!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", "!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", "!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 +49,18 @@ "!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/pgdelta*", "!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 +70,8 @@ "!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", "!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 9fd71b1d03..705f596254 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,393 +114,341 @@ 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 ()"); - const stderr = out.rawChunks.map((c) => c.text).join(""); - expect(stderr).not.toContain("Applying migration"); - expect(stderr).not.toContain("schema_paths is not applied"); - 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 ()"); + const stderr = out.rawChunks.map((c) => c.text).join(""); + expect(stderr).not.toContain("Applying migration"); + expect(stderr).not.toContain("schema_paths is not applied"); + }), ); it.effect( "falls back to migration files and warns that schema_paths is ignored 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 ()"); + expect(out.rawChunks.map((c) => c.text).join("")).toContain( + "[db.migrations].schema_paths is not applied while pg-delta is enabled", + ); + }), + ); + + 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 ()"); - expect(out.rawChunks.map((c) => c.text).join("")).toContain( - "[db.migrations].schema_paths is not applied while pg-delta is enabled", - ); - 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 ();", - ); - const { session, execs } = fakeSession(); - const out = mockOutput(); - return run( - workdir, - "20240101000000", - { - ...baseConfig, - experimental: true, - 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 }); - }), - ), - ); - }, + () => + 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( "warns that schema_paths is ignored even when migrations are disabled and pg-delta is enabled", - () => { - 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, + "", + { + ...baseConfig, + migrationsEnabled: false, + experimental: true, + pgDeltaEnabled: true, + schemaPaths: ["supabase/schemas/a.sql"], + }, + session, + out, + ); + expect(execs).not.toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + expect(out.rawChunks.map((c) => c.text).join("")).toContain( + "[db.migrations].schema_paths is not applied while pg-delta is enabled", + ); + }), + ); + + 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, "", { ...baseConfig, - migrationsEnabled: false, experimental: true, - pgDeltaEnabled: 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).not.toContain("create table migration_marker ()"); - expect(execs).not.toContain("create table schema_marker ()"); - expect(out.rawChunks.map((c) => c.text).join("")).toContain( - "[db.migrations].schema_paths is not applied while pg-delta is enabled", - ); - 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(); + () => + 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 a suggestion (See schema file: )", () => + Effect.gen(function* () { + const workdir = yield* makeWorkdir; + const schemaPath = "supabase/schemas/broken.sql"; + yield* writeFile(workdir, schemaPath, "totally not valid sql;"); + const { session } = failingExecSession(); const out = mockOutput(); - return run( + const error = yield* run( workdir, "", { ...baseConfig, experimental: true, - schemaPaths: ["supabase/schemas"], + schemaPaths: [schemaPath], }, 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 }); - }), - ), - ); - }, + ).pipe(Effect.flip); + assertMigrationApplyError(error); + expect(error.suggestion).toBeDefined(); + expect(stripAnsi(error.suggestion ?? "")).toBe(`See schema file: ${schemaPath}`); + }), ); - - it.effect("attaches the failing schema file as a suggestion (See schema file: )", () => { - const workdir = makeWorkdir(); - const schemaPath = "supabase/schemas/broken.sql"; - writeFile(workdir, schemaPath, "totally not valid sql;"); - const { session } = failingExecSession(); - const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - schemaPaths: [schemaPath], - }, - 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 }); - }), - ), - ); - }); }); -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", @@ -509,89 +461,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.unit.test.ts b/apps/cli/src/command-internal/migration-apply.unit.test.ts index f9f1cab291..001178fdc4 100644 --- a/apps/cli/src/command-internal/migration-apply.unit.test.ts +++ b/apps/cli/src/command-internal/migration-apply.unit.test.ts @@ -1,17 +1,14 @@ -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, Layer, 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, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; -import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; import { DbConnectError } from "./db-connection.errors.ts"; import type { DbBatchStatement, DbSession } from "./db-connection.service.ts"; import { @@ -112,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; @@ -126,739 +134,557 @@ 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 the parse-file error text", () => { - 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 }); - }), - ), - ); - }); + it.effect("wraps a read failure with the parse-file error text", () => + 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", () => { @@ -885,116 +711,97 @@ describe("hasTransactionControl", () => { }); }); -describe("migration failure rendering", () => { +layer(BunServices.layer)("migration failure rendering", (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", () => { @@ -1101,15 +908,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"); @@ -1117,504 +922,316 @@ 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", - () => { - // 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", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); + 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. - 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("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(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), + 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( "reports the last scanned RAW token in the too-long error even when it trimmed to empty", - () => { - 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("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(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), - ); - }, + () => + 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("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 (the buffer 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 the hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE is set but unparseable (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`); + () => + 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("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", () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-hex-", + "big.sql", + `SELECT 1;\nSELECT '${"a".repeat(5116)}';\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("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(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), + 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: "0x1400" }, body)), ); - it.effect("accepts a hex-literal SUPABASE_SCANNER_BUFFER_SIZE", () => { - 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("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(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), - ); - }); - - it.effect("accepts underscore digit separators in a decimal SUPABASE_SCANNER_BUFFER_SIZE", () => { - 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("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(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), - ); - }); + 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( "rejects an invalid underscore placement in SUPABASE_SCANNER_BUFFER_SIZE, unlike a valid digit separator", - () => { - 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(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), - ); - }, + }).pipe((body) => withConfigEnv({ SUPABASE_SCANNER_BUFFER_SIZE: "_5120" }, body)), ); it.effect( "falls back to the hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE overflows the signed int range", - () => { - 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("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(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), - ); - }, + () => + 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("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", - () => { - 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(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), - ); - }, + }).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", - () => { - 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("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("scanner: token too long"); + }).pipe(withEmptyConfigEnv), ); - it.effect("shell env still wins over the project env for SUPABASE_SCANNER_BUFFER_SIZE", () => { - 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" }, + it.effect("shell env still wins over the project env for SUPABASE_SCANNER_BUFFER_SIZE", () => + 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( - 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 }); - }), + }).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( + "treats an unresolvable shell SUPABASE_SCANNER_BUFFER_SIZE as unset and falls back to the project env", + () => + Effect.gen(function* () { + const { workdir } = yield* schemaFile( + "schema-files-scanner-config-error-", + "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("scanner: token too long"); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.make(() => + Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), + ), + ), + ), ), - Effect.provide(Layer.mergeAll(BunServices.layer, cliConfigProviderLayer)), - ); - }); + ); }); describe("revertsToLoginRole", () => { diff --git a/apps/cli/src/command-internal/migration-fetch-run.ts b/apps/cli/src/command-internal/migration-fetch-run.ts index 0f5cf8302d..3ee569443e 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 cd147689ae..0073012b0f 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)", () => { - 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", () => { @@ -186,7 +193,7 @@ describe("suggestRevertHistory", () => { }); describe("resolveMigrationFile (byte-ordered match)", () => { - 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)", () => { ), 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 d94efe30b9..47bfd4dcd5 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/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.unit.test.ts b/apps/cli/src/command-internal/pgdelta.unit.test.ts index 2546d3f124..f5aa331800 100644 --- a/apps/cli/src/command-internal/pgdelta.unit.test.ts +++ b/apps/cli/src/command-internal/pgdelta.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { ConfigProvider, Effect } from "effect"; -import { withConfigEnv } from "../../tests/helpers/command-mocks.ts"; +import { withConfigEnv, withEmptyConfigEnv } from "../../tests/helpers/command-mocks.ts"; import { edgeRuntimeId, isPgDeltaDebugEnabled, isPostgresURL } from "./pgdelta.ts"; describe("isPostgresURL", () => { @@ -32,6 +32,21 @@ describe("isPgDeltaDebugEnabled", () => { it.effect("is false otherwise", () => Effect.gen(function* () { expect(yield* withConfigEnv({ PGDELTA_DEBUG: "0" }, isPgDeltaDebugEnabled)).toBe(false); + expect(yield* withEmptyConfigEnv(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 4351a516de..957a41dad7 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -503,7 +503,7 @@ const handleVectorError = Effect.fnUntraced(function* ( summary.vector_skipped = true; return; } - return yield* Effect.fail(error); + return yield* error; }); const uploadObjects = Effect.fnUntraced(function* ( @@ -603,7 +603,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 @@ -614,7 +614,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/command-internal/seed-ops.unit.test.ts b/apps/cli/src/command-internal/seed-ops.unit.test.ts index 93979e1795..0b9c118b8f 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,46 +32,45 @@ 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)", () => { - it.effect("treats a leading `!` in a bracket class as literal, not negation", () => { - 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; +layer(BunServices.layer)("getPendingSeeds (glob character classes)", (it) => { + it.effect("treats a leading `!` in a bracket class as literal, not negation", () => + 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( "warns the bad-pattern message for an unterminated bracket class, not a bogus no-match", () => { // 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)); }, ); }); @@ -94,104 +91,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", - () => { - 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( + "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 28434f7b69..f15a8ff9cc 100644 --- a/apps/cli/src/command-internal/seed.unit.test.ts +++ b/apps/cli/src/command-internal/seed.unit.test.ts @@ -1,14 +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 { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.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 }> = []; @@ -27,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, @@ -37,137 +42,119 @@ 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, cliConfigProviderLayer))); + }).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", () => { - 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 }); - }), - ), - ); - }); + it.effect("expands a matched directory to its sorted, regular .sql files", () => + 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", - () => { - 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( + "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 }); - }), - ), - ); - }); -}); 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 c54f8a7de3..8548877417 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -673,6 +673,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("diffs local with pg-delta when no [experimental.pgdelta] config is present", () => { const s = setup(tmp.current, { diffSql: "create table p ();\n" }); return Effect.gen(function* () { 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 eece71b633..6151b4d219 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, @@ -163,6 +162,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { projectRef?: string; projectId: string; strictCoverage: boolean; + debug: boolean; }> = []; let engineDiffCount = 0; const pgDeltaEngine = Layer.succeed( @@ -176,6 +176,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) { @@ -188,26 +189,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); @@ -253,6 +247,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( @@ -981,6 +976,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", () => { @@ -1427,11 +1433,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", () => @@ -1452,9 +1459,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.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 1e45ec2d43..8b4440047f 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( @@ -1015,6 +1017,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.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 7330b2ee0e..79a281987d 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 @@ -311,6 +311,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, @@ -325,9 +326,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; @@ -418,6 +420,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, localPostgresImageChecks, declarativeExportCalls, + planDebug, get planCalls() { return planCalls; }, @@ -986,6 +989,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", () => { 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..c131dd09cb --- /dev/null +++ b/apps/cli/src/commands/start/start.health.e2e.test.ts @@ -0,0 +1,162 @@ +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 { serviceContainerName } from "../../command-internal/docker-ids.ts"; +import { sanitizeProjectId } from "../../shared/config/project-id.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 30c29b14b6..565d199326 100644 --- a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts +++ b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts @@ -11,10 +11,7 @@ import { } from "../../../tests/helpers/cli.ts"; import { serviceContainerName, localDbContainerId } from "../../command-internal/docker-ids.ts"; import { sanitizeProjectId } from "../../shared/config/project-id.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; @@ -269,121 +266,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 } }