From 87e47b5effbd1b6743aa6349fcf5f3992eb02125 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Wed, 14 Oct 2026 09:35:36 +0000 Subject: [PATCH 1/4] chore(cli): map db lint, push, reset and related command permissions (#7135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps `db start`, `db test`, `db lint`, `db advisors`, `db reset`, `db push` and `seed buckets` to the Management API operations they can call, so scoped PATs can be granted per command. Drift tests keep each declaration in step with what the command does. **Shared model change.** `withCondition` now accepts a database-target condition as well as a flag condition, so a command can gate a block on the target it selects (`--local`, `--db-url`, linked). `FlagCondition` is no longer exported. **Blocks used.** `dbConfig` (lint, advisors, reset, push, `db test`), `linkedProjectCache` (all but `db start` and `db test`), `storageCredentials` (`seed buckets`). `db advisors` adds its own security and performance entries, keyed on `--type`; because `--type` is optional, each operation has an entry for the listed values and one for the flag being absent. **Defaults.** `db lint`, `db advisors`, `db reset`, `db test` and `seed buckets` default to the local target; `db push` defaults to linked. Each handler reads its target from the command line, not from the parsed flag values. **Tests.** Lint, advisors, reset, push and `db test` run the real database-config resolver and the real linked-project cache and check the recorded requests; `--local` runs assert no Management API request. `seed buckets` records the api-keys request (`reveal=true`) and the cache fill. Also covered: `--password` skipping the login role, an advisors 403 stopping before the performance call, and `--linked=false` in lint and seed. The `node:net` mock the real-resolver tests need now lives in one helper, `tests/helpers/refuse-invalid-db-hosts.ts`, and replaces the copies in `db query` and the resolver tests. **Not covered.** - `--linked=false` on push, reset, advisors and `db test`. - A default target declared linked when it is really local would not fail a drift test: a local run records no requests. Also corrects the API routes listed in the `db pull` and `db diff` side-effects docs (`/cli/login-role`, `/config/database/pooler`, `POST /network-bans/retrieve`); they listed paths the code does not call. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../command-permissions/model.ts | 12 +- .../db-config.integration.test.ts | 18 +- .../commands/db/advisors/advisors.command.ts | 42 +++++ .../db/advisors/advisors.integration.test.ts | 167 ++++++++++++++++- apps/cli/src/commands/db/diff/SIDE_EFFECTS.md | 13 +- apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 9 +- apps/cli/src/commands/db/lint/lint.command.ts | 18 ++ .../commands/db/lint/lint.integration.test.ts | 169 +++++++++++++++++- apps/cli/src/commands/db/pull/SIDE_EFFECTS.md | 16 +- apps/cli/src/commands/db/push/push.command.ts | 27 +++ .../commands/db/push/push.integration.test.ts | 140 ++++++++++++++- .../db/query/query.integration.test.ts | 18 +- .../src/commands/db/reset/reset.command.ts | 18 ++ .../db/reset/reset.integration.test.ts | 138 +++++++++++++- .../src/commands/db/start/start.command.ts | 2 + .../db/start/start.integration.test.ts | 35 +++- apps/cli/src/commands/db/test/test.command.ts | 4 + .../commands/db/test/test.integration.test.ts | 115 +++++++++++- .../commands/seed/buckets/buckets.command.ts | 17 ++ .../seed/buckets/buckets.integration.test.ts | 153 +++++++++++++++- .../helpers/permission-pending-commands.ts | 8 - .../tests/helpers/permission-real-layers.ts | 72 +++++++- .../tests/helpers/refuse-invalid-db-hosts.ts | 21 +++ 23 files changed, 1141 insertions(+), 91 deletions(-) create mode 100644 apps/cli/tests/helpers/refuse-invalid-db-hosts.ts diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index 50a2e8735d..a76bb2f14d 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -47,9 +47,12 @@ interface ValueCondition extends FlagConditionBase { readonly is?: never; } -export type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition; +type FlagCondition = PresenceCondition | SwitchCondition | ValueCondition; -/** The database a command talks to: `--local`, `--db-url`, or the linked project. */ +/** + * The target a command talks to: `--local`, `--db-url`, or the linked project. `seed buckets` uses + * it for its Storage target, which `--linked`/`--local` select the same way. + */ export type DbTarget = "local" | "db-url" | "linked"; /** @@ -142,11 +145,12 @@ export function compose(...fragments: ReadonlyArray): Permis /** * Adds `condition` to every operation entry in `fragment`, ahead of each entry's own `when`. It - * keeps no block `source`, so it must name a flag of the command that declares it. + * keeps no block `source`, so a flag condition must name a flag of the command that declares it, + * and a target condition a target that command can select. */ export function withCondition( fragment: PermissionFragment, - condition: FlagCondition, + condition: EntryCondition, ): PermissionFragment { return { ...fragment, diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index c66f2c093e..d20a8f6dca 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -1,4 +1,3 @@ -import type { NetConnectOpts } from "node:net"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { @@ -19,20 +18,11 @@ import * as TestClock from "effect/testing/TestClock"; import type { DatabaseInstance, Stack } from "@supabase/stack/effect"; import { vi } from "vitest"; -// Keep reserved `.invalid` fixture hosts from depending on ambient DNS/TCP timing. vi.mock("node:net", (importOriginal) => - importOriginal().then((net) => ({ - ...net, - connect: (options: NetConnectOpts) => { - const host = "host" in options ? options.host : undefined; - if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) { - return net.connect(options); - } - const socket = new net.Socket(); - queueMicrotask(() => socket.emit("error", new Error("fixture connection refused"))); - return socket; - }, - })), + Promise.all([ + import("../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); import { diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index 5c6895540f..84745ae969 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -1,5 +1,13 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbAdvisors } from "./advisors.handler.ts"; @@ -44,6 +52,40 @@ export type DbAdvisorsFlags = CliCommand.Command.Config.Infer; export const dbAdvisorsCommand = Command.make("advisors", config).pipe( Command.withDescription("Checks database for security and performance issues."), Command.withShortDescription("Checks database for security and performance issues"), + withPermissions( + withDbTarget( + compose( + { + operations: [ + { + operationId: "v1-get-security-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "security"] }], + }, + { + operationId: "v1-get-security-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], + }, + { + operationId: "v1-get-performance-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "performance"] }], + }, + { + operationId: "v1-get-performance-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], + }, + ], + noApiEffectFlags: ["level", "fail-on"], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "local" }, + ), + ), Command.withHandler((flags) => dbAdvisors(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 44ac73dafc..bf9ea066a0 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -1,5 +1,14 @@ +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted } from "effect"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { @@ -11,7 +20,18 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, sequentialExecBatch, + useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; @@ -230,6 +250,8 @@ interface SetupOpts { performanceLints?: ReadonlyArray>; /** Raw CLI args for `CliArgs` β€” drives DB target selection (Changed-based). */ args?: ReadonlyArray; + /** Runs the real `DbConfigResolver` and linked-project cache against the temp `workdir`. */ + realLayers?: { readonly workdir: string }; } function setup(opts: SetupOpts = {}) { @@ -281,12 +303,22 @@ function setup(opts: SetupOpts = {}) { if (url.includes("/advisors/performance")) { return Effect.succeed(jsonResponse(request, 200, { lints: opts.performanceLints ?? [] })); } + if (request.method === "GET" && url.endsWith(`/v1/projects/${VALID_REF}`)) { + return Effect.succeed( + jsonResponse(request, 200, { + ref: VALID_REF, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + }), + ); + } return Effect.succeed(jsonResponse(request, 404, {})); }, }); const cliSettings = mockCommandSettings({ - workdir: "/tmp/advisors-int", + workdir: opts.realLayers?.workdir ?? "/tmp/advisors-int", accessToken: opts.loggedIn === false ? Option.none() : undefined, }); const credentials = mockCredentials({ @@ -296,12 +328,20 @@ function setup(opts: SetupOpts = {}) { const layer = Layer.mergeAll( out.layer, - resolver.layer, + opts.realLayers === undefined + ? resolver.layer + : realDbConfigResolver({ + workdir: opts.realLayers.workdir, + dbConnection: connection.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }), connection.layer, telemetry.layer, processControl.layer, projectRef.layer, - cache.layer, + opts.realLayers === undefined + ? cache.layer + : realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), cliSettings, credentials.layer, identityStitch.layer, @@ -335,9 +375,14 @@ const flags = (over: Partial = {}): DbAdvisorsFlags => ({ describe("db advisors β€” local", () => { it.effect("queries the local database and prints the Go pretty JSON array", () => { - const { layer, out, connection } = setup({ rows: [lintRow()] }); + const { layer, out, connection, api } = setup({ rows: [lintRow()] }); return Effect.gen(function* () { yield* dbAdvisors(flags()); + assertPermissionDrift({ + command: "db advisors", + requests: api.requests, + exact: true, + }); const expected = encodeAdvisorLints([scanAdvisorLintRow(lintRow())]); expect(out.stdoutText).toBe(expected); expect(out.stderrText).toContain("Connecting to local database..."); @@ -520,6 +565,7 @@ describe("db advisors β€” local", () => { }); describe("db advisors β€” linked", () => { + const tempRoot = useTempWorkdir("supabase-db-advisors-int-"); const securityLint = { name: "rls_disabled_in_public", title: "RLS disabled", @@ -537,6 +583,97 @@ describe("db advisors β€” linked", () => { cache_key: "perf", }; + it.effect( + "mints a login role, fetches both advisors and fills the cache through the real layers", + () => { + const { layer, api } = setup({ + securityLints: [securityLint], + performanceLints: [performanceLint], + args: ["db", "advisors", "--linked", "--project-ref", VALID_REF], + realLayers: { workdir: tempRoot.current }, + }); + const resolverRequests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(resolverRequests, serveLinkedDatabase(VALID_REF)); + yield* dbAdvisors(flags({ linked: true, projectRef: Option.some(VALID_REF) })); + expect(resolverRequests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/advisors/security`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}/advisors/performance`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe( + true, + ); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { linked: true, "project-ref": VALID_REF }, + requests: [...asRecordedRequests(resolverRequests), ...api.requests], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); + + it.effect("makes no Management API request for --local through the real layers", () => { + const { layer, api } = setup({ + rows: [], + args: ["db", "advisors", "--local"], + realLayers: { workdir: tempRoot.current }, + }); + const resolverRequests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(resolverRequests, () => undefined); + yield* dbAdvisors(flags({ local: true })); + expect(resolverRequests).toEqual([]); + expect(api.requests).toEqual([]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe( + false, + ); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect( + "fails on a 403 from the security advisors without asking for the performance ones", + () => { + const { layer, api } = setup({ securityStatus: 403, args: ["--linked"] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("all") }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("unexpected security advisors status 403"); + } + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/advisors/security`, + ]); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { linked: true, type: "all" }, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("fetches both security and performance advisors for --type all", () => { const { layer, out, api, cache } = setup({ securityLints: [securityLint], @@ -544,10 +681,16 @@ describe("db advisors β€” linked", () => { args: ["--linked"], }); return Effect.gen(function* () { - yield* dbAdvisors(flags({ level: Option.some("info") })); + yield* dbAdvisors(flags({ type: Option.some("all"), level: Option.some("info") })); const urls = api.requests.map((r) => r.url); expect(urls.some((u) => u.includes("/advisors/security"))).toBe(true); expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(true); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { linked: true, type: "all", level: true }, + requests: api.requests, + exact: true, + }); expect(out.stdoutText).toContain("rls_disabled_in_public"); expect(out.stdoutText).toContain("unindexed_foreign_keys"); expect(cache.cached).toBe(true); @@ -664,6 +807,12 @@ describe("db advisors β€” linked", () => { const urls = api.requests.map((r) => r.url); expect(urls.some((u) => u.includes("/advisors/security"))).toBe(true); expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(false); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { linked: true, type: "security" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -674,6 +823,12 @@ describe("db advisors β€” linked", () => { const urls = api.requests.map((r) => r.url); expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(true); expect(urls.some((u) => u.includes("/advisors/security"))).toBe(false); + assertPermissionDrift({ + command: "db advisors", + activeFlags: { linked: true, type: "performance" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 9556a0282f..d342688c2f 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -90,12 +90,13 @@ it, and JSON `null` disables formatting without disabling safe compaction. ## API Routes (linked path, via the db-config resolver) -| Method | Path | Auth | Purpose | -| ---------- | ---------------------------------- | ------ | -------------------------------- | -| POST | `/v1/projects/{ref}/roles` | Bearer | Temp login role when no password | -| GET | `/v1/projects/{ref}/pooler/config` | Bearer | IPv4 pooler fallback | -| GET/DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | -| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | +| Method | Path | Auth | Purpose | +| ------ | ------------------------------------------- | ------ | ----------------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | Temp login role when no password | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | IPv4 pooler fallback | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | List bans during pooler login retry | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | +| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | `--use-pgadmin --linked` performs every one of these calls natively (CLI-1968), as part of this command's own target resolve, ahead of the differ container. diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 9e4f37646e..9a545e46f7 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -24,10 +24,11 @@ image), to stdout or `--file`. ## API Routes -| Method | Path | Auth | When | -| ------ | -------------------------------------------- | ------ | ------------------------------------------------------------ | -| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | `--linked` with no `DB_PASSWORD` (mint a temp postgres role) | -| GET | `/v1/projects/{ref}/network-bans` (+ DELETE) | Bearer | `--linked` pooler temp-role retry (clear self ban) | +| Method | Path | Auth | When | +| ------ | ------------------------------------------ | ------ | ------------------------------------------------------------ | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | `--linked` with no `DB_PASSWORD` (mint a temp postgres role) | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | `--linked` pooler temp-role retry (list bans) | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | `--linked` pooler temp-role retry (clear self ban) | (All via the shared `DbConfigResolver` `--linked` path.) diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index b99cbe438e..ffd1f93557 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -1,5 +1,13 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; @@ -50,6 +58,16 @@ export type DbLintFlags = CliCommand.Command.Config.Infer; export const dbLintCommand = Command.make("lint", config).pipe( Command.withDescription("Checks local database for typing error."), Command.withShortDescription("Checks local database for typing error"), + withPermissions( + withDbTarget( + compose( + { operations: [], noApiEffectFlags: ["schema", "level", "fail-on"] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "local" }, + ), + ), Command.withHandler((flags) => dbLint(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/lint/lint.integration.test.ts b/apps/cli/src/commands/db/lint/lint.integration.test.ts index ba55fd2cd2..3b9e6c8397 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -1,13 +1,36 @@ +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, + VALID_TOKEN, + mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, sequentialExecBatch, + useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; @@ -174,6 +197,8 @@ interface SetupOpts { listFails?: boolean; /** Raw CLI args for `CliArgs` β€” drives DB target selection (Changed-based). */ args?: ReadonlyArray; + /** Runs the real `DbConfigResolver` and linked-project cache against the temp `workdir`. */ + realLayers?: { readonly workdir: string }; } function setup(opts: SetupOpts = {}) { @@ -191,18 +216,30 @@ function setup(opts: SetupOpts = {}) { const processControl = mockProcessControl(); const projectRef = mockProjectRef(); const cache = mockLinkedProjectCacheTracked(); + const http = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( out.layer, - resolver.layer, + opts.realLayers === undefined + ? resolver.layer + : realDbConfigResolver({ + workdir: opts.realLayers.workdir, + dbConnection: connection.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }), connection.layer, telemetry.layer, processControl.layer, projectRef.layer, - cache.layer, + opts.realLayers === undefined + ? cache.layer + : realLinkedProjectCache({ + httpClient: http.layer, + cliSettings: mockCommandSettings({ workdir: opts.realLayers.workdir }), + }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: opts.args ?? [] }), ); - return { layer, out, resolver, connection, telemetry, processControl, projectRef, cache }; + return { layer, out, resolver, connection, telemetry, processControl, projectRef, cache, http }; } const flags = (over: Partial = {}): DbLintFlags => ({ @@ -216,6 +253,8 @@ const flags = (over: Partial = {}): DbLintFlags => ({ }); describe("db lint", () => { + const tempRoot = useTempWorkdir("supabase-db-lint-int-"); + it.effect("lints the named schema and prints parsed issues to stdout", () => { const { layer, out, connection } = setup({ schemas: [], @@ -498,6 +537,128 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); + it.effect("mints a login role and fills the cache through the real resolver and cache", () => { + const { layer, http } = setup({ + isLocal: false, + checkRows: { public: [] }, + args: ["db", "lint", "--linked", "--project-ref", VALID_REF], + realLayers: { workdir: tempRoot.current }, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbLint( + flags({ schema: ["public"], linked: true, projectRef: Option.some(VALID_REF) }), + ); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(http.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db lint", + activeFlags: { linked: true, "project-ref": VALID_REF, schema: true }, + requests: [...asRecordedRequests(requests), ...http.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect( + "makes no Management API request for --local through the real resolver and cache", + () => { + const { layer, http } = setup({ + checkRows: { public: [] }, + args: ["db", "lint", "--local"], + realLayers: { workdir: tempRoot.current }, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbLint(flags({ schema: ["public"], local: true })); + expect(requests).toEqual([]); + expect(http.calls).toEqual([]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe( + false, + ); + assertPermissionDrift({ + command: "db lint", + activeFlags: { local: true, schema: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); + + it.effect("defaults to the local target and makes no Management API request", () => { + const { layer, http } = setup({ + checkRows: { public: [] }, + args: ["db", "lint"], + realLayers: { workdir: tempRoot.current }, + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* dbLint(flags({ schema: ["public"] })); + expect(requests).toEqual([]); + expect(http.calls).toEqual([]); + assertPermissionDrift({ + command: "db lint", + activeFlags: { schema: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("--linked=false still reaches the linked database through the real resolver", () => { + const { layer, http } = setup({ + isLocal: false, + checkRows: { public: [] }, + args: ["db", "lint", "--linked=false", "--project-ref", VALID_REF], + realLayers: { workdir: tempRoot.current }, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbLint(flags({ schema: ["public"], projectRef: Option.some(VALID_REF) })); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(yield* fs.exists(`${tempRoot.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db lint", + activeFlags: { linked: false, "project-ref": VALID_REF, schema: true }, + requests: [...asRecordedRequests(requests), ...http.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(layer, BunServices.layer)), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + it.effect("does not write the linked-project cache for a local run", () => { const { layer, cache } = setup({ checkRows: { public: [] } }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 81db74a60e..1ba637c263 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -115,13 +115,15 @@ at all, so nothing is cached for it. ## API Routes / DB -| Method | Path / SQL | Auth | Purpose | -| ------ | --------------------------------------------------- | ------ | -------------------------------- | -| POST | `/v1/projects/{ref}/roles` | Bearer | Temp login role when no password | -| GET | `/v1/projects/{ref}/pooler/config` | Bearer | IPv4 pooler fallback | -| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | -| SQL | `SELECT version FROM …schema_migrations` | β€” | history reconciliation (remote) | -| SQL | `INSERT … ON CONFLICT … schema_migrations` (UPSERT) | β€” | history update (on confirmation) | +| Method | Path / SQL | Auth | Purpose | +| ------ | --------------------------------------------------- | ------ | ----------------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | Temp login role when no password | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | IPv4 pooler fallback | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | List bans during pooler login retry | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | +| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | +| SQL | `SELECT version FROM …schema_migrations` | β€” | history reconciliation (remote) | +| SQL | `INSERT … ON CONFLICT … schema_migrations` (UPSERT) | β€” | history update (on confirmation) | ## Environment Variables diff --git a/apps/cli/src/commands/db/push/push.command.ts b/apps/cli/src/commands/db/push/push.command.ts index 623008cbe8..1245f6a140 100644 --- a/apps/cli/src/commands/db/push/push.command.ts +++ b/apps/cli/src/commands/db/push/push.command.ts @@ -1,6 +1,14 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbPush } from "./push.handler.ts"; @@ -66,6 +74,25 @@ export const dbPushCommand = Command.make("push", config).pipe( "Push new migrations to the remote database. Vault secrets from config.toml are updated before migrations unless --skip-vault is set.", ), Command.withShortDescription("Push new migrations to the remote database"), + withPermissions( + withDbTarget( + compose( + { + operations: [], + noApiEffectFlags: [ + "include-all", + "include-roles", + "include-seed", + "skip-vault", + "dry-run", + ], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "linked" }, + ), + ), Command.withHandler((flags) => dbPush(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/push/push.integration.test.ts b/apps/cli/src/commands/db/push/push.integration.test.ts index 708ec3ad82..c2f5aff5d5 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -3,10 +3,30 @@ import { createHash } from "node:crypto"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { VALID_REF, + VALID_TOKEN, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -174,6 +194,8 @@ function setup( // fired as part of `resolve()`'s own connection work, strictly before `dbPushCore` // runs; `mockResolver` is otherwise silent, so tests pin output ordering against it. simulateInitialisingLoginRole?: boolean; + /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ + realLayers?: boolean; }, ) { const workdirLayer = Layer.effectDiscard( @@ -225,11 +247,18 @@ function setup( } : undefined, }); + const projectHttp = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( workdirLayer, out.layer, conn.layer, - resolver.layer, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: conn.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver.layer, mockCommandSettings({ workdir, ...(opts.noProjectId === true ? { projectId: Option.none() } : {}), @@ -244,7 +273,12 @@ function setup( Layer.succeed(DnsResolverFlag, "native"), projectRefLayer, telemetry.layer, - linkedCache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : linkedCache.layer, ); return { layer, @@ -253,6 +287,7 @@ function setup( telemetry, linkedCache, resolver, + projectHttp, }; } @@ -881,6 +916,107 @@ describe("db push", () => { }); }); + it.effect("mints a login role and fills the cache through the real resolver and cache", () => { + // No target flag: push defaults to the linked project. + const { layer, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--project-ref", VALID_REF], + isLocal: false, + realLayers: true, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbPush({ + ...DEFAULT_FLAGS, + local: false, + projectRef: Option.some(VALID_REF), + }).pipe(Effect.provide(layer)); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(projectHttp.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db push", + activeFlags: { "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("does not mint a login role when --password is given", () => { + const { layer, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--linked", "--project-ref", VALID_REF, "--password", "secret"], + isLocal: false, + realLayers: true, + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbPush({ + ...DEFAULT_FLAGS, + local: false, + linked: true, + projectRef: Option.some(VALID_REF), + password: Option.some("secret"), + }).pipe(Effect.provide(layer)); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + ]); + assertPermissionDrift({ + command: "db push", + activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect( + "makes no Management API request for --local through the real resolver and cache", + () => { + const { layer, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--local"], + realLayers: true, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(requests).toEqual([]); + expect(projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "db push", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); + it.effect("surfaces an apply error with statement context", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/commands/db/query/query.integration.test.ts b/apps/cli/src/commands/db/query/query.integration.test.ts index 10fbfb7c7d..5e6f21f8bf 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -1,4 +1,3 @@ -import type { NetConnectOpts } from "node:net"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { @@ -19,20 +18,11 @@ import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { vi } from "vitest"; -// Keep reserved `.invalid` fixture hosts from depending on ambient DNS/TCP timing. vi.mock("node:net", (importOriginal) => - importOriginal().then((net) => ({ - ...net, - connect: (options: NetConnectOpts) => { - const host = "host" in options ? options.host : undefined; - if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) { - return net.connect(options); - } - const socket = new net.Socket(); - queueMicrotask(() => socket.emit("error", new Error("fixture connection refused"))); - return socket; - }, - })), + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), ); import { diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index 646e810c85..9e24d0c7c6 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -1,6 +1,14 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbReset } from "./reset.handler.ts"; @@ -53,6 +61,16 @@ export type DbResetFlags = CliCommand.Command.Config.Infer; export const dbResetCommand = Command.make("reset", config).pipe( Command.withDescription("Resets the local database to current migrations."), Command.withShortDescription("Resets the local database to current migrations"), + withPermissions( + withDbTarget( + compose( + { operations: [], noApiEffectFlags: ["no-seed", "sql-paths", "version", "last"] }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "local" }, + ), + ), Command.withHandler((flags) => dbReset(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index a3193835d3..9d97038f6d 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -18,6 +18,14 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HttpBody from "effect/http/HttpBody"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { mockOutput, @@ -28,6 +36,7 @@ import { } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, + VALID_TOKEN, jsonResponse, withConfigEnv, withEmptyConfigEnv, @@ -41,6 +50,17 @@ import { sequentialExecBatch, transportFailure, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; @@ -908,6 +928,8 @@ function setup( stackUnavailableAfterReset?: boolean; stackStatusFailure?: StackError; httpClient?: Layer.Layer; + /** Runs the real `DbConfigResolver` and linked-project cache against `workdir`. */ + realLayers?: boolean; }, ) { const workdirLayer = Layer.effectDiscard( @@ -988,10 +1010,17 @@ function setup( : Effect.succeed(jsonResponse(request, matched.status ?? 200, matched.body ?? {})); }), ); + const projectHttp = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( out.layer, conn.layer, - resolver.layer, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: conn.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver.layer, mockCommandSettings({ workdir }), BunServices.layer, child.layer, @@ -1029,7 +1058,12 @@ function setup( Layer.succeed(ExperimentalFlag, opts.experimental ?? false), Layer.succeed(DebugFlag, opts.debug ?? false), telemetry.layer, - linkedCache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : linkedCache.layer, ...(opts.stackBackend === true && catalog !== undefined ? [stackBackendLayer("stack"), stackApi.layer, catalog.layer] : []), @@ -1045,6 +1079,7 @@ function setup( stackApi, catalogApplied: catalog?.applied ?? [], requests, + projectHttp, }; } @@ -2577,6 +2612,105 @@ describe("db reset", () => { }); }); + it.effect("mints a login role and fills the cache through the real resolver and cache", () => { + const { layer, conn, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "reset", "--linked", "--project-ref", VALID_REF], + confirm: [true], + realLayers: true, + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbReset({ + ...DEFAULT_FLAGS, + linked: true, + projectRef: Option.some(VALID_REF), + }).pipe(Effect.provide(layer)); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(projectHttp.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(conn.execs.some((sql) => sql.includes("drop schema if exists"))).toBe(true); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db reset", + activeFlags: { linked: true, "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("defaults to the local target and makes no Management API request", () => { + const { layer, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + realLayers: true, + }); + const requests: Array = []; + return withEnvVar( + "GITHUB_HEAD_REF", + undefined, + Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(requests).toEqual([]); + expect(projectHttp.calls).toEqual([]); + assertPermissionDrift({ command: "db reset", requests: [], exact: true }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ), + ); + }); + + it.effect( + "makes no Management API request for --local through the real resolver and cache", + () => { + const { layer, projectHttp } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + realLayers: true, + }); + const requests: Array = []; + return withEnvVar( + "GITHUB_HEAD_REF", + undefined, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbReset({ ...DEFAULT_FLAGS, local: true }).pipe(Effect.provide(layer)); + expect(requests).toEqual([]); + expect(projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe( + false, + ); + assertPermissionDrift({ + command: "db reset", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ), + ); + }, + ); + it.effect("fails a remote reset before dropping schemas on an undecryptable secret", () => { // Every secret is decrypted while loading config, before the reset runs, so an // undecryptable secret must abort before any destructive work. diff --git a/apps/cli/src/commands/db/start/start.command.ts b/apps/cli/src/commands/db/start/start.command.ts index 7475eaf92d..143057c965 100644 --- a/apps/cli/src/commands/db/start/start.command.ts +++ b/apps/cli/src/commands/db/start/start.command.ts @@ -1,6 +1,7 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { dbStart } from "./start.handler.ts"; @@ -18,6 +19,7 @@ export type DbStartFlags = CliCommand.Command.Config.Infer; export const dbStartCommand = Command.make("start", config).pipe( Command.withDescription("Starts local Postgres database."), Command.withShortDescription("Starts local Postgres database"), + withPermissions({ operations: [], noApiEffectFlags: ["from-backup"] }), Command.withHandler((flags) => dbStart(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index ee9219fc7e..2819f8661e 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -23,6 +23,7 @@ import { mockProcessControl, mockRuntimeInfo, } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; import { mockCommandSettings, @@ -245,12 +246,19 @@ function runningCheckFailsRoute( }; } -const alwaysReadyHttpClientLayer = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), - ), -); +function alwaysReadyHttpClient() { + const calls: Array<{ readonly method: string; readonly url: string }> = []; + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + calls.push({ method: request.method, url: request.url }); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })), + ); + }), + ); + return { layer, calls }; +} /** PG15+ (this suite's default) never calls `exec`/`query` directly β€” its schema init is three one-shot `DockerRun` jobs instead. */ function fakeDbSession() { @@ -329,6 +337,7 @@ function setup(opts: SetupOpts = {}) { }), ).pipe(Layer.provide(BunServices.layer)); const out = mockOutput({ format: opts.format ?? "text" }); + const http = alwaysReadyHttpClient(); const telemetry = mockTelemetryStateTracked(); const cliSettings = mockCommandSettings({ workdir }); const baseRoute = opts.route ?? defaultRoute(); @@ -375,7 +384,7 @@ function setup(opts: SetupOpts = {}) { telemetry.layer, child.layer, mockLocalDockerEngineUnavailableLayer, - alwaysReadyHttpClientLayer, + http.layer, dbConnection, unusedStackApi, dockerRunLayer.pipe(Layer.provide(child.layer), Layer.provide(mockProcessControl().layer)), @@ -404,6 +413,7 @@ function setup(opts: SetupOpts = {}) { child, dbSession, catalogApplied, + httpCalls: http.calls, get connectAttempts() { return connectAttempts; }, @@ -470,7 +480,7 @@ describe("db start", () => { it.effect( "starts the database on a fresh volume: creates the container, runs the SetupLocalDatabase-equivalent pipeline, and writes _current_branch", () => { - const { layer, out, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + const { layer, out, child, httpCalls } = setup({ route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Starting database...\n"); @@ -481,6 +491,7 @@ describe("db start", () => { expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); expect(yield* readCurrentBranch(tempRoot.current)).toBe("main"); expect(out.stderrText).not.toContain("Finished"); + assertPermissionDrift({ command: "db start", requests: httpCalls, exact: true }); }); }, ); @@ -671,7 +682,7 @@ describe("db start", () => { ); it.effect("resolves a relative --from-backup against the caller cwd, not the workdir", () => { - const { layer, child } = setup({ + const { layer, child, httpCalls } = setup({ route: freshVolumeRoute(defaultRoute()), cwd: "/caller/here", }); @@ -679,6 +690,12 @@ describe("db start", () => { yield* dbStart(flags("dump.sql")).pipe(Effect.provide(layer)); const args = createArgs(child.spawned); expect(bindsFromCreateArgs(args ?? [])).toContain("/caller/here/dump.sql:/etc/backup.sql:ro"); + assertPermissionDrift({ + command: "db start", + activeFlags: { "from-backup": true }, + requests: httpCalls, + exact: true, + }); }); }); diff --git a/apps/cli/src/commands/db/test/test.command.ts b/apps/cli/src/commands/db/test/test.command.ts index 4cbf589f2d..659ae15df2 100644 --- a/apps/cli/src/commands/db/test/test.command.ts +++ b/apps/cli/src/commands/db/test/test.command.ts @@ -1,5 +1,8 @@ import { Command } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { withDbTarget } from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { TEST_DB_DESCRIPTION, TEST_DB_SHORT, @@ -17,6 +20,7 @@ import { testDbRuntimeLayer } from "../../../command-internal/test-db.layers.ts" export const dbTestCommand = Command.make("test", testDbConfig).pipe( Command.withDescription(TEST_DB_DESCRIPTION), Command.withShortDescription(TEST_DB_SHORT), + withPermissions(withDbTarget(dbConfig, { default: "local" })), Command.withHandler(runTestDbCommand), // `["db", "test"]`, not `["test", "db"]`: the `cli_command_executed` // telemetry records the actual invoked command path, which differs by diff --git a/apps/cli/src/commands/db/test/test.integration.test.ts b/apps/cli/src/commands/db/test/test.integration.test.ts index 27f16d3bcd..674131458e 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -9,6 +9,14 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option, Stdio } from "effect"; import { CliOutput, Command } from "effect/cli"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { mockOutput, @@ -18,10 +26,21 @@ import { mockTty, } from "../../../../tests/helpers/mocks.ts"; import { + VALID_TOKEN, mockCommandSettings, mockTelemetryStateTracked, sequentialExecBatch, + useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; @@ -154,10 +173,17 @@ const runtimeInfoLayer = Layer.succeed(RuntimeInfo, { pid: 1234, }); +const REF = "abcdefghijklmnopqrst"; + interface SetupOpts { format?: "text" | "json" | "stream-json"; exitCode?: number; stdout?: ReadonlyArray; + /** The command-line arguments the handler reads its database target from. */ + args?: ReadonlyArray; + /** Runs the real `DbConfigResolver` against the temp `workdir`; the test serves its Management API calls. */ + realDbConfig?: boolean; + workdir?: string; } function setup(opts: SetupOpts = {}) { @@ -168,24 +194,31 @@ function setup(opts: SetupOpts = {}) { const connection = mockDbConnection(); const docker = mockDockerRun({ exitCode: opts.exitCode, stdout: opts.stdout }); const args = ["db", "test"]; + const workdir = opts.workdir ?? "/work/project"; const layer = Layer.mergeAll( unusedStackServices, out.layer, processControl.layer, analytics.layer, telemetry.layer, - mockResolver(), + opts.realDbConfig === true + ? realDbConfigResolver({ + workdir, + dbConnection: connection.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : mockResolver(), connection.layer, docker.layer, Layer.succeed(BundledPostgresClient, { run: () => Effect.die("bundled postgres client unused"), }), - mockCommandSettings({ workdir: "/work/project", projectId: Option.none() }), + mockCommandSettings({ workdir, projectId: Option.none() }), runtimeInfoLayer, Layer.succeed(DebugFlag, false), Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: opts.args ?? [] }), Stdio.layerTest({ args: Effect.succeed(args) }), commandRuntimeLayer(["db", "test"]).pipe(Layer.provide(BunServices.layer)), BunServices.layer, @@ -202,6 +235,8 @@ const flags = (): TestDbFlags => ({ }); describe("db test (alias) integration", () => { + const tempRoot = useTempWorkdir("supabase-db-test-int-"); + it.effect("runs pgTAP through the alias exactly like `test db`", () => { const { layer, connection, docker } = setup(); return Effect.gen(function* () { @@ -214,6 +249,80 @@ describe("db test (alias) integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("mints a login role through the real DB config resolver for --linked", () => { + const { layer, docker } = setup({ + args: ["db", "test", "--linked", "--project-ref", REF], + realDbConfig: true, + workdir: tempRoot.current, + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(REF)); + yield* runTestDbCommand({ + ...flags(), + linked: true, + local: false, + projectRef: Option.some(REF), + }); + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${REF}/config/database/pooler`, + `POST /v1/projects/${REF}/cli/login-role`, + ]); + expect(docker.lastOpts?.cmd[0]).toBe("pg_prove"); + assertPermissionDrift({ + command: "db test", + activeFlags: { linked: true, "project-ref": REF }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("defaults to the local target and makes no Management API request", () => { + const { layer, docker } = setup({ realDbConfig: true, workdir: tempRoot.current }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* runTestDbCommand(flags()); + expect(docker.lastOpts?.cmd[0]).toBe("pg_prove"); + expect(requests).toEqual([]); + assertPermissionDrift({ command: "db test", requests: [], exact: true }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("makes no Management API request for --local through the real resolver", () => { + const { layer, docker } = setup({ + args: ["db", "test", "--local"], + realDbConfig: true, + workdir: tempRoot.current, + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* runTestDbCommand(flags()); + expect(docker.lastOpts?.cmd[0]).toBe("pg_prove"); + expect(requests).toEqual([]); + assertPermissionDrift({ + command: "db test", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + it.effect( "dispatches through the real `dbTestCommand` and records `db test`, not `test db`, as the telemetry command", () => { diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index 18994ae3fc..bae17db679 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -2,6 +2,14 @@ import { Effect, Layer, type Option } from "effect"; import { Command, Flag } from "effect/cli"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { storageCredentials } from "../../../command-internal/storage-credentials.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { SeedLinkedFlag, SeedLocalFlag } from "../seed.flags.ts"; @@ -29,6 +37,15 @@ export type BucketsFlags = { export const bucketsCommand = Command.make("buckets", config).pipe( Command.withDescription("Seed buckets declared in [storage.buckets]."), Command.withShortDescription("Seed buckets declared in [storage.buckets]"), + withPermissions( + withDbTarget( + compose( + { operations: [], noApiEffectFlags: ["project-ref"] }, + withCondition(compose(storageCredentials, linkedProjectCache), { dbTarget: ["linked"] }), + ), + { default: "local" }, + ), + ), Command.withHandler((leafFlags) => Effect.gen(function* () { // Enforce --local/--linked mutual exclusivity before instrumentation, so a diff --git a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts index 443863c354..2fce5f2bb1 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -24,6 +24,7 @@ import { transportFailure, withEnvVar, mockCommandSettings, + mockCommandPlatformApi, mockCommandPlatformApiService, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -52,6 +53,8 @@ import { type SetupStorageStackApiOptions, } from "../../../../tests/helpers/storage.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; interface MockRoute { readonly method: string; @@ -104,6 +107,8 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( /** Selects the `stack` backend; omitted/false keeps the legacy default. */ readonly stackBackend?: boolean; readonly stackApi?: SetupStorageStackApiOptions; + /** Records the Management API requests through the real factory and runs the real linked-project cache. */ + readonly recordManagementApi?: boolean; }, ) { const fs = yield* FileSystem.FileSystem; @@ -219,6 +224,26 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( }, }); + const recorded = + opts.recordManagementApi === true + ? mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.method === "GET" && request.url.endsWith(`/v1/projects/${projectRefRef}`) + ? jsonResponse(request, 200, { + ref: projectRefRef, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + }) + : request.method === "GET" && + request.url.endsWith(`/v1/projects/${projectRefRef}/api-keys`) + ? jsonResponse(request, 200, opts.apiKeys ?? defaultApiKeys) + : jsonResponse(request, 404, { message: "no mock route" }), + ), + }) + : undefined; + const stackApi = buildStorageStackApi(workdir, opts.stackApi); const layer = Layer.mergeAll( @@ -235,15 +260,30 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( Layer.succeed(CliArgs, { args: opts.args ?? ["seed", "buckets"] }), Layer.succeed(YesFlag, opts.yes ?? false), projectRefLayer, - Layer.succeed(CommandPlatformApiFactory, { - make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), - }), - linkedCache.layer, + recorded === undefined + ? Layer.succeed(CommandPlatformApiFactory, { + make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), + }) + : recorded.factoryLayer, + recorded === undefined + ? linkedCache.layer + : realLinkedProjectCache({ + httpClient: recorded.httpClientLayer, + cliSettings: mockCommandSettings({ workdir }), + }), unusedStackServices, ...(opts.stackBackend === true ? [stackBackendLayer("stack"), stackApi.layer] : []), ); - return { layer, out, requests, telemetry, linkedCache, stackCalls: stackApi.stackCalls }; + return { + layer, + out, + requests, + telemetry, + linkedCache, + stackCalls: stackApi.stackCalls, + managementRequests: recorded?.requests ?? [], + }; }); const VECTOR_LIST = "/storage/v1/vector/ListVectorBuckets"; @@ -2082,6 +2122,94 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); + it.effect("fetches the revealed api keys and fills the cache through the real layers", () => + Effect.gen(function* () { + const { layer, requests, managementRequests } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + projectRef: VALID_REF, + args: ["seed", "buckets", "--linked"], + recordManagementApi: true, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + const fs = yield* FileSystem.FileSystem; + yield* seedBuckets({ linked: true, local: false, projectRef: Option.none() }).pipe( + Effect.provide(layer), + ); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(requests.length).toBeGreaterThan(0); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "seed buckets", + activeFlags: { linked: true }, + requests: managementRequests, + exact: true, + }); + }).pipe(seedScenario), + ); + + it.effect("asks for no api keys when SUPABASE_AUTH_SERVICE_ROLE_KEY is set", () => + seedScenario( + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "env-service-role-key", + Effect.gen(function* () { + const { layer, managementRequests } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + projectRef: VALID_REF, + args: ["seed", "buckets", "--linked"], + recordManagementApi: true, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + yield* seedBuckets({ linked: true, local: false, projectRef: Option.none() }).pipe( + Effect.provide(layer), + ); + expect(managementRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + assertPermissionDrift({ + command: "seed buckets", + activeFlags: { linked: true }, + requests: managementRequests, + }); + }), + ), + ), + ); + + it.effect("makes no Management API request for the local target", () => + Effect.gen(function* () { + const { layer, requests, managementRequests } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + recordManagementApi: true, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + const fs = yield* FileSystem.FileSystem; + yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(requests.length).toBeGreaterThan(0); + expect(managementRequests).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "seed buckets", + requests: managementRequests, + exact: true, + }); + }).pipe(seedScenario), + ); + it.effect( "--project-ref --linked seeds the project given by the flag, overriding VALID_REF", () => @@ -2140,9 +2268,10 @@ describe("seed buckets", () => { it.effect("--linked=false still takes the linked path (Go flag.Changed, not value)", () => Effect.gen(function* () { - const { layer, requests } = yield* setupSeedBuckets(tmp.current, { + const { layer, requests, managementRequests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, + recordManagementApi: true, args: ["seed", "buckets", "--linked=false"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, @@ -2158,6 +2287,18 @@ describe("seed buckets", () => { expect(requests.every((r) => r.url.startsWith(`https://${VALID_REF}.supabase.co`))).toBe( true, ); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + assertPermissionDrift({ + command: "seed buckets", + activeFlags: { linked: false }, + requests: managementRequests, + exact: true, + }); }).pipe(seedScenario), ); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index 0e595022c1..349601ee57 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -30,23 +30,17 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "config pull", "config push", - "db advisors", "db branch create", "db branch delete", "db branch list", "db branch switch", "db diff", "db dump", - "db lint", "db pull", - "db push", "db remote changes", "db remote commit", - "db reset", "db schema declarative generate", "db schema declarative sync", - "db start", - "db test", "domains activate", "domains create", @@ -145,8 +139,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "secrets set", "secrets unset", - "seed buckets", - "services", "snippets download", diff --git a/apps/cli/tests/helpers/permission-real-layers.ts b/apps/cli/tests/helpers/permission-real-layers.ts index d3716913d9..c7ada964a6 100644 --- a/apps/cli/tests/helpers/permission-real-layers.ts +++ b/apps/cli/tests/helpers/permission-real-layers.ts @@ -1,6 +1,7 @@ import { BunServices } from "@effect/platform-bun"; import { ConfigProvider, Effect, Layer, Option } from "effect"; -import type * as HttpClient from "effect/http/HttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { dbConfigLayer } from "../../src/command-internal/db-config.layer.ts"; import type { DbConfigResolver } from "../../src/command-internal/db-config.service.ts"; @@ -52,7 +53,7 @@ export function realLinkedProjectCache(opts: { /** * The real `DbConfigResolver` on the linked path: the direct host (`db..invalid`) is - * unreachable once the test file mocks `node:net` to refuse it, so the resolver falls back to + * unreachable once the test file mocks `node:net` with `refuseInvalidDbHosts`, so the resolver falls back to * the pooler and mints a login role through {@link stubManagementApi}. Provide the same `env` * to the running effect as a `ConfigProvider`, since the resolver reads it when it resolves. */ @@ -141,3 +142,70 @@ export const primaryPoolerConfigs = (ref: string) => [ pool_mode: "transaction", }, ]; + +/** A Management API request as {@link stubManagementApi} records it. */ +export interface StubbedRequest { + readonly method: string; + readonly path: string; +} + +/** The recorded requests in the shape the drift check takes. */ +export const asRecordedRequests = (requests: ReadonlyArray) => + requests.map(({ method, path }) => ({ method, url: `https://api.supabase.com${path}` })); + +/** Serves the pooler config and a login role for `ref`, which is what the resolver asks for on the linked path. */ +export const serveLinkedDatabase = + (ref: string) => + (method: string, path: string): Response | undefined => { + if (method === "GET" && path === `/v1/projects/${ref}/config/database/pooler`) { + return Response.json(primaryPoolerConfigs(ref)); + } + if (method === "POST" && path === `/v1/projects/${ref}/cli/login-role`) { + return Response.json( + { role: "cli_login_role", password: "temporary-role-password", ttl_seconds: 3600 }, + { status: 201 }, + ); + } + return undefined; + }; + +/** Provides `env` to the effect as a `ConfigProvider`, the way {@link realDbConfigResolver} reads it. */ +export const provideEnv = (env: Record) => + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true }), + ); + +/** + * An `HttpClient` that records each request and answers the project lookup for `ref`, which is + * what the real linked-project cache fetches. + */ +export function recordingProjectHttpClient(ref: string) { + const calls: Array<{ readonly method: string; readonly url: string }> = []; + const layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + calls.push({ method: request.method, url: request.url }); + const found = request.method === "GET" && request.url.endsWith(`/v1/projects/${ref}`); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + found + ? Response.json({ + ref, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + }) + : Response.json({ message: "unexpected request" }, { status: 404 }), + ), + ); + }), + ); + return { + layer, + get calls() { + return calls; + }, + }; +} diff --git a/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts new file mode 100644 index 0000000000..711b719801 --- /dev/null +++ b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts @@ -0,0 +1,21 @@ +import type * as NodeNet from "node:net"; + +/** + * Wraps `node:net` so a connection to a `db..invalid` fixture host fails at once instead of + * waiting on ambient DNS or TCP timing. Use it from a test file's `vi.mock("node:net", ...)` + * factory; other hosts connect normally. + */ +export function refuseInvalidDbHosts(net: typeof NodeNet) { + return { + ...net, + connect: (options: NodeNet.NetConnectOpts) => { + const host = "host" in options ? options.host : undefined; + if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) { + return net.connect(options); + } + const socket = new net.Socket(); + queueMicrotask(() => socket.emit("error", new Error("fixture connection refused"))); + return socket; + }, + }; +} From 28dad9c763b2ea3f61439acc0f09b0fa5131a7c1 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:43:50 +0000 Subject: [PATCH 2/4] chore(stack): bump storage to v1.82.0-r0 (#7140) This bumps storage from v1.81.5-r0 to v1.82.0-r0. https://github.com/supabase/slim-services/releases/tag/storage-v1.82.0-r0 Planned after supabase/slim-services published v1.82.0-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index c851417adc..8ddea5a6ac 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.4 AS realtime -FROM supabase/storage-api:v1.81.5 AS storage +FROM supabase/storage-api:v1.82.0 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index c851417adc..8ddea5a6ac 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.4 AS realtime -FROM supabase/storage-api:v1.81.5 AS storage +FROM supabase/storage-api:v1.82.0 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index bd4ef400e6..184195ea71 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -267,23 +267,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.81.5", + upstreamVersion: "v1.82.0", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.81.5-r0@sha256:0d200e4c0b47f5dfbede9b850429543a5363c0d1798e9c5ca670015766dc99b6", - upstreamImage: "supabase/storage-api:v1.81.5", + "ghcr.io/supabase/cli/storage:v1.82.0-r0@sha256:c71adb01633ce0147162f53fd5d4fa861fcf2707586707f6d88acfaa8db2eeb4", + upstreamImage: "supabase/storage-api:v1.82.0", natives: { "darwin-arm64": { - archive: "d7a7d4ea3743460b2cb117ff4de968ac6e6d66f44cffeaca7f3683412767b6d9", - manifest: "da0629092a687b83b47b9e44351a624a500839442505515b0e04ba7384fa92b3", + archive: "e225cd6ce14cd8ee1ede02c8e3b14e366ae2342920d904b6bd30727cbc1fd6ab", + manifest: "b2e126721482284b60cb32d82b530e17e9603cbe95cbfcc35dcb7fcedad0696d", }, "linux-amd64": { - archive: "6cab4b6b93e123c4e8e9d4808d81c68658a1563a93874c64d506f740c8acf3a4", - manifest: "4eb224328de931085948c0c769b5c95cf52e9e1292418672f49199e967623c50", + archive: "de14d47658694a3d084d26bd12c0d2ec8ff30a7abbc1dcdcbc7fa1b098010360", + manifest: "5de5ef546a2f579b6c2dd025eaf9052013849218db80449b87feda23b8d8db59", }, "linux-arm64": { - archive: "8b69c856197413f2ebba455f7ad711568be9d9600ede90240eb6fc9ce8e47c6e", - manifest: "d6650aaf8c78fab72187171e996444b6e58c0a47f24e02dd17549dcdf0262482", + archive: "3e436461a05a18579da27b3734df0c367195731046c54044a0f81e442dccec39", + manifest: "a9eed25be05723dc34a2f7240cf8127c3d3c85fbfb82fae903f470122debf2fa", }, }, }, From cb2985f8de8e385378566b09c8d78ebbdef7b64c Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Wed, 14 Oct 2026 10:10:19 +0000 Subject: [PATCH 3/4] chore(cli): map db dump, pull, diff and declarative command permissions (#7136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps `db dump`, `db pull`, `db remote commit`, `db diff`, `db schema declarative generate` and `db schema declarative sync` to the Management API operations they can call, so scoped PATs can be granted per command. Drift tests keep each declaration in step with what the command does. Stacked on the previous db PR. **Shared changes.** - `dbConfigPoolerFallback` (new block): the pooler retry after a linked dump, declarative export or pull diff fails with an IPv6 error against the direct host. `dbConfig` is now built from a shared entries list with the linked target prepended; its declaration is unchanged. - `dbConfigLinked` (new block): the same calls without the target condition, for commands that reach the linked path some other way. - `withContext` (new builder): records a prompt choice or other non-flag prerequisite on every entry of a fragment. **Defaults.** `db dump`, `db pull` and `db remote commit` default to the linked project; `db diff` defaults to local. `db dump`, `db pull` and `db remote commit` declare `dbConfig`, the fallback block and the linked-project cache. `db diff` declares `dbConfig` and the cache for `--linked`, plus the linked calls for a `--from` or `--to` side set to `linked`, which resolves the project without `--linked`. `generate` declares them for `--linked` and for the target prompt (no target flag, migrations present). `sync` has no target flags, so it declares them for the bootstrap prompt only. **Tests.** Each command runs the real database-config resolver and the real linked-project cache and checks the recorded requests, including `--password` skipping the login role and `--local` making no request. Dump and pull also run the container IPv6 retry (login role, pooler config, login role). `db diff` covers `--from linked`, `--to linked` and both. The `node:net` mock gains a scoped `withReachableDirectHost` helper for the direct-host path. **Not covered.** - `--use-pg-schema` on `db diff` hands off to Go before any TypeScript call; it is declared as having no API effect here. - The generate and sync prompt-path entries and the fallback entries carry a prerequisite, so the exact check skips them; tests assert their request sequences directly. - `dbConfig` and `dbConfigPoolerFallback` cover the same operations under the linked target in dump, pull and remote commit, so a drift test cannot tell a missing `dbConfig` from a present one there. Both are tested on their own. - `db remote commit` has no retry test; it shares pull's code. - `--linked=false` and `--local=false` on these commands rely on the existing handler tests. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../command-permissions/model.ts | 17 ++ .../db-config.integration.test.ts | 124 +++++++- .../src/command-internal/db-config.layer.ts | 76 ++++- apps/cli/src/commands/db/diff/diff.command.ts | 36 +++ .../commands/db/diff/diff.integration.test.ts | 191 +++++++++++- apps/cli/src/commands/db/dump/dump.command.ts | 31 ++ .../commands/db/dump/dump.integration.test.ts | 204 ++++++++++++- apps/cli/src/commands/db/pull/pull.command.ts | 28 ++ .../commands/db/pull/pull.integration.test.ts | 284 +++++++++++++++++- .../db/remote/commit/commit.command.ts | 19 ++ .../declarative/generate/generate.command.ts | 44 +++ .../generate/generate.integration.test.ts | 201 ++++++++++++- .../schema/declarative/sync/sync.command.ts | 28 ++ .../declarative/sync/sync.integration.test.ts | 138 ++++++++- .../helpers/permission-pending-commands.ts | 6 - .../tests/helpers/refuse-invalid-db-hosts.ts | 38 ++- 16 files changed, 1422 insertions(+), 43 deletions(-) diff --git a/apps/cli/src/command-internal/command-permissions/model.ts b/apps/cli/src/command-internal/command-permissions/model.ts index a76bb2f14d..9d65e3fb61 100644 --- a/apps/cli/src/command-internal/command-permissions/model.ts +++ b/apps/cli/src/command-internal/command-permissions/model.ts @@ -161,6 +161,23 @@ export function withCondition( }; } +/** + * Adds `prerequisite` to every entry's `context`, for a condition that isn't a flag, e.g. a choice + * the user makes at a prompt. + */ +export function withContext( + fragment: PermissionFragment, + prerequisite: string, +): PermissionFragment { + return { + ...fragment, + operations: fragment.operations.map((entry) => ({ + ...entry, + context: entry.context === undefined ? prerequisite : `${prerequisite}, and ${entry.context}`, + })), + }; +} + /** * Gives every entry in `fragment` the `kind` the including command handles it with, when that * differs from the block's own (e.g. a command that carries on after a 403 on a call its diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index d20a8f6dca..a787b37606 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -41,7 +41,12 @@ import { } from "../../tests/helpers/permission-real-layers.ts"; import { applicableOperations } from "../../tests/helpers/permission-registry.ts"; import type { ActiveFlags } from "../../tests/helpers/permission-registry.ts"; -import { dbConfig, dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts"; +import { + dbConfig, + dbConfigLayer, + dbConfigPoolerFallback, + dbConfigResolverLayer, +} from "./db-config.layer.ts"; import { DebugFlag, DnsResolverFlag, @@ -1792,3 +1797,120 @@ describe("dbConfigResolver (Management API calls)", () => { ); } }); + +describe("dbConfigResolver (container pooler fallback)", () => { + const ref = "fallbackrefabcdefghi"; + const poolerPath = `/v1/projects/${ref}/config/database/pooler`; + const loginRolePath = `/v1/projects/${ref}/cli/login-role`; + const session: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + const connects = Layer.succeed(DbConnection, { connect: () => Effect.succeed(session) }); + const assertFallbackDrift = ( + requests: ReadonlyArray<{ readonly method: string; readonly path: string }>, + activeFlags: ActiveFlags, + ) => + assertBlockDrift(dbConfigPoolerFallback, { + activeFlags, + requests: requests.map((r) => ({ + method: r.method, + url: `https://api.supabase.com${r.path}`, + })), + exact: true, + }); + + it.effect("fetches the pooler config, then mints a login role when no password is set", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => { + if (method === "GET" && path === poolerPath) + return Response.json(primaryPoolerConfigs(ref)); + if (method === "POST" && path === loginRolePath) { + return Response.json( + { role: "cli_login_role", password: "temporary-role-password", ttl_seconds: 3600 }, + { status: 201 }, + ); + } + return undefined; + }); + const dir = yield* withWorkdir(); + const pooler = yield* resolvePoolerFallback( + dir, + { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, + ); + expect(Option.isSome(pooler)).toBe(true); + if (Option.isSome(pooler)) { + expect(pooler.value).toMatchObject({ + host: "aws-0-us-east-1.pooler.supabase.com", + user: `cli_login_role.${ref}`, + password: "temporary-role-password", + }); + } + expect(requests).toEqual([ + { method: "GET", path: poolerPath }, + { method: "POST", path: loginRolePath }, + ]); + assertFallbackDrift(requests, { linked: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("fetches the pooler config but mints no login role when a password is set", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => + method === "GET" && path === poolerPath + ? Response.json(primaryPoolerConfigs(ref)) + : undefined, + ); + const dir = yield* withWorkdir(); + const pooler = yield* resolvePoolerFallback( + dir, + { ...linkedFlags, linkedProjectRef: Option.some(ref), password: Option.some("secret") }, + { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, + ); + expect(pooler).toEqual( + Option.some(expect.objectContaining({ user: `postgres.${ref}`, password: "secret" })), + ); + expect(requests).toEqual([{ method: "GET", path: poolerPath }]); + assertFallbackDrift(requests, { linked: true, password: "secret" }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("returns no pooler when the pooler config is forbidden", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, (method, path) => + method === "GET" && path === poolerPath + ? Response.json({ message: "Forbidden" }, { status: 403 }) + : undefined, + ); + const dir = yield* withWorkdir(); + const pooler = yield* resolvePoolerFallback( + dir, + { ...linkedFlags, linkedProjectRef: Option.some(ref) }, + { dbConnection: connects, configEnv: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN } }, + ); + expect(Option.isNone(pooler)).toBe(true); + expect(requests).toEqual([{ method: "GET", path: poolerPath }]); + assertFallbackDrift(requests, { linked: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("--local returns no pooler and makes no Management API request", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + const dir = yield* withWorkdir(); + const pooler = yield* resolvePoolerFallback(dir, localFlags); + expect(Option.isNone(pooler)).toBe(true); + expect(requests).toEqual([]); + assertFallbackDrift(requests, { local: true }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); +}); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 9caec2ad57..345f443517 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -41,50 +41,98 @@ import { mapHttpError } from "./http-errors.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackLocalDatabaseConn } from "./stack-local-database.ts"; -import { block } from "./command-permissions/model.ts"; +import { block, type OperationEntry } from "./command-permissions/model.ts"; -/** - * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands - * include it with `withDbTarget`. `gen types --project-id` and `db dump`'s container fallback - * fetch the pooler config without `--project-ref`, so those commands declare that entry - * themselves. - */ -export const dbConfig = block("db-config", [ +const linkedEntries: ReadonlyArray> = [ { operationId: "v1-create-login-role", kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: - "no database password from --password (an empty value counts as none), SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)", + "no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)", }, { operationId: "v1-get-pooler-config", kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "project-ref" }], + when: [{ flag: "project-ref" }], context: "the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", }, { operationId: "v1-get-pooler-config", kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "project-ref", oneOf: [""] }], + when: [{ flag: "project-ref", oneOf: [""] }], context: "an empty --project-ref falls back to the linked project but still allows the pooler fetch, when the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url", }, { operationId: "v1-list-all-network-bans", kind: "best-effort", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: "after three failed attempts to connect through the pooler as the temporary login role", }, { operationId: "v1-delete-network-bans", kind: "best-effort", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: "after three failed attempts to connect through the pooler as the temporary login role", }, +]; + +/** + * The same calls as {@link dbConfig} without its target condition, for a command that reaches the + * linked path some other way: `db diff --from linked`, a `--linked` flag with no default target, or + * a target prompt. + */ +export const dbConfigLinked = block("db-config", linkedEntries); + +/** + * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands + * include it with `withDbTarget`. The container retry that `db dump`, `db pull` and + * `db remote commit` run is {@link dbConfigPoolerFallback}; `gen types --project-id` fetches the + * pooler config without `--project-ref`, so that command declares its own entry. + */ +export const dbConfig = block( + "db-config", + linkedEntries.map((entry) => ({ + ...entry, + when: [{ dbTarget: ["linked" as const] }, ...(entry.when ?? [])], + })), +); + +/** + * `DbConfigResolver.resolvePoolerFallback`, the pooler retry after a linked dump, declarative export + * or pull diff fails with an IPv6 connectivity error against a direct host the CLI could reach. + */ +export const dbConfigPoolerFallback = block("db-config-pooler-fallback", [ + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [{ dbTarget: ["linked"] }], + context: + "a linked dump, declarative export or pull diff fails with an IPv6 connectivity error against the direct database host, and there is no usable saved supabase/.temp/pooler-url", + }, + { + operationId: "v1-create-login-role", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + context: + "the same retry, and no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env", + }, + { + operationId: "v1-list-all-network-bans", + kind: "best-effort", + when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + context: "the same retry, after three failed attempts to connect as the temporary login role", + }, + { + operationId: "v1-delete-network-bans", + kind: "best-effort", + when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + context: "the same retry, after three failed attempts to connect as the temporary login role", + }, ]); const DIRECT_PORT = 5432; diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index 3e7b1da8a6..8b6c4b6281 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -1,6 +1,14 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig, dbConfigLinked } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -98,11 +106,39 @@ const config = { export type DbDiffFlags = CliCommand.Command.Config.Infer; +// An explicit `--from`/`--to linked` side resolves the linked project without `--linked`. +const explicitLinkedSide = (flag: "from" | "to") => + withCondition(compose(dbConfigLinked, linkedProjectCache), { flag, oneOf: ["linked"] }); + export const dbDiffCommand = Command.make("diff", config).pipe( Command.withDescription( "Compares a shadow built from supabase/migrations with a live database (--local by default, --linked, or --db-url). Declarative files under supabase/schemas are not part of this baseline. Output is printed by default; in normal mode, -f names and saves the complete diff as a migration and does not filter objects. Explicit --from/--to output is flattened review SQL, not a portable apply script.", ), Command.withShortDescription("Diffs the local database for schema changes"), + withPermissions( + withDbTarget( + compose( + { + operations: [], + noApiEffectFlags: [ + "use-migra", + "use-pgadmin", + "use-pg-schema", + "use-pg-delta", + "strict-coverage", + "output", + "file", + "schema", + ], + }, + dbConfig, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + explicitLinkedSide("from"), + explicitLinkedSide("to"), + ), + { default: "local" }, + ), + ), Command.withHandler((flags) => dbDiff(flags).pipe( withCommandTelemetry({ 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 e4531ecb64..6d010518ca 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -18,11 +18,31 @@ import { import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { vi } from "vitest"; + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net)), +); import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { FAKE_SHADOW_CONTAINER_ID, VALID_REF, + VALID_TOKEN, failWriteStringMatchingFsLayer, failWriteStringOnNthCallFsLayer, withConfigEnv, @@ -141,6 +161,8 @@ interface SetupOpts { readonly statefulDocker?: boolean; readonly dirs?: ReadonlyArray; readonly files?: Readonly>; + // Runs the real resolver and linked-project cache; `workdir` must be a real directory. + readonly realLayers?: boolean; } const alwaysReadyHttpClientLayer = Layer.succeed( @@ -440,6 +462,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), ).pipe(Layer.provide(BunServices.layer)); + const projectHttp = recordingProjectHttpClient(VALID_REF); const baseLayer = Layer.mergeAll( unusedStackServices, // Listed first so the fake service layers below (`Layer.mergeAll` is last-wins) @@ -447,7 +470,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { BunServices.layer, out.layer, telemetry.layer, - cache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, pgDeltaEngine, edge, docker, @@ -455,10 +483,20 @@ function setup(workdir: string, opts: SetupOpts = {}) { dockerDaemon?.layer ?? shadowSpawnerLayer, mockLocalDockerEngineUnavailableLayer, alwaysReadyHttpClientLayer, - resolver, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: shadowDbConnection.layer, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver, projectRefResolver, proxy, - mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }), + mockCommandSettings({ + workdir, + projectId: opts.projectId ?? Option.some("test"), + ...(opts.realLayers === true ? { projectHost: "invalid" } : {}), + }), Layer.succeed(DnsResolverFlag, "native"), Layer.succeed( NetworkIdFlag, @@ -507,6 +545,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { shadowConnectedDatabases: shadowDbConnection.connectedDatabases, shadowExecCalls: shadowDbConnection.execCalls, shadowConnectRefused: shadowDbConnection.shadowConnectRefused, + projectHttp, }; } @@ -2665,3 +2704,149 @@ describe("db diff", () => { }); }); }); + +describe("db diff permissions through the real resolver and cache", () => { + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const poolerRequest = `GET /v1/projects/${VALID_REF}/config/database/pooler`; + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; + const cacheCalls = (calls: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + calls.map(({ method, url }) => `${method} ${url}`); + + it.effect("mints a login role and fills the cache for --linked", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "alter table x;\n" }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDiff(flags({ linked: Option.some(true), projectRef: Option.some(VALID_REF) })).pipe( + Effect.provide(s.layer), + ); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db diff", + activeFlags: { linked: true, "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("makes no Management API request for the default local target", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "alter table x;\n" }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbDiff(flags()).pipe(Effect.provide(s.layer)); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "db diff", + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + for (const side of ["from", "to"] as const) { + it.effect(`resolves the linked project for --${side} linked without --linked`, () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); + const requests: Array = []; + const refs = + side === "from" + ? { from: "linked", to: "migrations" } + : { from: "migrations", to: "linked" }; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDiff( + flags({ + from: Option.some(refs.from), + to: Option.some(refs.to), + projectRef: Option.some(VALID_REF), + }), + ).pipe(Effect.provide(s.layer)); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db diff", + activeFlags: { from: refs.from, to: refs.to, "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + } + + it.effect("makes no Management API request for --from local --to migrations", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, () => undefined); + yield* dbDiff(flags({ from: Option.some("local"), to: Option.some("migrations") })).pipe( + Effect.provide(s.layer), + ); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + assertPermissionDrift({ + command: "db diff", + activeFlags: { from: "local", to: "migrations" }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("mints a login role for each side when both --from and --to are linked", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDiff( + flags({ + from: Option.some("linked"), + to: Option.some("linked"), + projectRef: Option.some(VALID_REF), + }), + ).pipe(Effect.provide(s.layer)); + expect(asLines(requests)).toEqual([ + poolerRequest, + loginRoleRequest, + poolerRequest, + loginRoleRequest, + ]); + assertPermissionDrift({ + command: "db diff", + activeFlags: { from: "linked", to: "linked", "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); +}); diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index 42abd7d09f..cd04c5ec4b 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -2,6 +2,14 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig, dbConfigPoolerFallback } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { ProcessControl } from "../../../shared/runtime/process-control.service.ts"; @@ -106,6 +114,29 @@ export type DbDumpFlags = CliCommand.Command.Config.Infer; export const dbDumpCommand = Command.make("dump", config).pipe( Command.withDescription("Dumps data or schemas from the remote database."), Command.withShortDescription("Dumps data or schemas from the remote database"), + withPermissions( + withDbTarget( + compose( + { + operations: [], + noApiEffectFlags: [ + "dry-run", + "data-only", + "use-copy", + "exclude", + "role-only", + "keep-comments", + "file", + "schema", + ], + }, + dbConfig, + dbConfigPoolerFallback, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "linked" }, + ), + ), Command.withHandler((flags) => dbDump(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 1fb7b1d89b..44e45ab7ff 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -14,11 +14,38 @@ import { Stream, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { vi } from "vitest"; + +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + +const reachableDirectHost = withReachableDirectHost(fixtureNet); import { mockOutput, mockTty, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; +import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { VALID_REF, + VALID_TOKEN, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -37,9 +64,14 @@ import { PROJECT_NOT_LINKED_MESSAGE, PROJECT_REF_PATTERN, } from "../../../config/project-ref.service.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags } from "../../../command-internal/db-config.types.ts"; -import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; +import { + DbConnection, + type DbSession, + type PgConnInput, +} from "../../../command-internal/db-connection.service.ts"; import { DbConfigConnectTempRoleError } from "../../../command-internal/db-config.errors.ts"; import { DockerRunError } from "../../../command-internal/docker-run.errors.ts"; import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker-run.service.ts"; @@ -184,6 +216,15 @@ const unusedStackApi = Layer.succeed(StackApi, { findDeleted: () => Effect.die("unused"), }); +const connectedSession: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), +}; + function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean; @@ -408,6 +449,8 @@ interface SetupOpts { stdoutIsPipe?: boolean; env?: Readonly>; dockerInstalled?: boolean; + /** Runs the real resolver and linked-project cache, with `workdir` a real directory. */ + realLayers?: boolean; } function setup(opts: SetupOpts = {}) { @@ -429,22 +472,42 @@ function setup(opts: SetupOpts = {}) { }); const docker = mockDockerRun(opts); const bundled = mockBundledPostgresClient(opts); + const projectHttp = recordingProjectHttpClient(VALID_REF); + const workdir = opts.workdir ?? "/work/project"; const layer = Layer.mergeAll( out.layer, - resolver.layer, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: Layer.succeed(DbConnection, { + connect: () => Effect.succeed(connectedSession), + }), + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver.layer, projectRef.layer, docker.layer, bundled.layer, unusedStackApi, mockCommandSettings({ - workdir: opts.workdir ?? "/work/project", + workdir, projectId: opts.projectId ?? Option.none(), + ...(opts.realLayers === true ? { projectHost: "invalid" } : {}), }), telemetry.layer, - cache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, runtimeInfoLayer(opts.platform ?? "linux", opts.arch), mockTty({ stdoutIsPipe: opts.stdoutIsPipe }), - processEnvLayer(opts.env ?? {}), + processEnvLayer( + opts.realLayers === true + ? { SUPABASE_ACCESS_TOKEN: VALID_TOKEN, ...opts.env } + : (opts.env ?? {}), + ), Layer.succeed( NetworkIdFlag, opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), @@ -452,7 +515,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(DnsResolverFlag, "native"), BunServices.layer, ); - return { layer, out, telemetry, resolver, docker, bundled, cache }; + return { layer, out, telemetry, resolver, docker, bundled, cache, projectHttp }; } const flags = (over: Partial = {}): DbDumpFlags => ({ @@ -1276,3 +1339,132 @@ describe("db dump integration", () => { } }); }); + +describe("db dump permissions through the real resolver and cache", () => { + const tmp = useTempWorkdir("supabase-db-dump-"); + const IPV6_STDERR = + 'could not translate host name "db.abcdefghijklmnopqrst.invalid" to address: No address associated with hostname'; + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const poolerRequest = `GET /v1/projects/${VALID_REF}/config/database/pooler`; + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + + it.effect("mints a login role and fills the cache for the default linked target", () => { + const { layer, out, projectHttp } = setup({ + workdir: tmp.current, + realLayers: true, + stdout: "CREATE SCHEMA x;\n", + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDump(flags({ projectRef: Option.some(VALID_REF) })).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("CREATE SCHEMA x;\n"); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + expect(projectHttp.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db dump", + activeFlags: { "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + // The resolve-phase calls belong to the database config block itself, not to the + // fallback block, which declares the same operations. + assertBlockDrift(dbConfig, { + activeFlags: { linked: true, "project-ref": VALID_REF }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("does not mint a login role when --password is given", () => { + const { layer, projectHttp } = setup({ + workdir: tmp.current, + realLayers: true, + stdout: "CREATE SCHEMA x;\n", + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDump( + flags({ + linked: Option.some(true), + projectRef: Option.some(VALID_REF), + password: Option.some("secret"), + }), + ).pipe(Effect.provide(layer)); + expect(asLines(requests)).toEqual([poolerRequest]); + assertPermissionDrift({ + command: "db dump", + activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + // The resolve-phase calls belong to the database config block itself, not to the + // fallback block, which declares the same operations. + assertBlockDrift(dbConfig, { + activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("makes no Management API request for --local and caches nothing", () => { + const { layer, projectHttp } = setup({ + workdir: tmp.current, + realLayers: true, + stdout: "CREATE SCHEMA x;\n", + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbDump(flags({ local: Option.some(true) })).pipe(Effect.provide(layer)); + expect(requests).toEqual([]); + expect(projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "db dump", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); + + it.effect("retries through the pooler after a container IPv6 failure", () => { + const { layer, docker, projectHttp } = setup({ + workdir: tmp.current, + realLayers: true, + results: [ + { exitCode: 1, stderr: IPV6_STDERR }, + { exitCode: 0, stdout: "CREATE SCHEMA x;\n" }, + ], + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + const fs = yield* FileSystem.FileSystem; + // No --project-ref: the linked ref comes from the saved file, so only the fallback block + // accounts for the pooler config request. + yield* fs.makeDirectory(`${tmp.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tmp.current}/supabase/.temp/project-ref`, VALID_REF); + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDump(flags()).pipe(Effect.provide(layer)); + expect(docker.allOpts).toHaveLength(2); + expect(docker.allOpts[1]?.env["PGHOST"]).toBe("aws-0-us-east-1.pooler.supabase.com"); + expect(asLines(requests)).toEqual([loginRoleRequest, poolerRequest, loginRoleRequest]); + assertPermissionDrift({ + command: "db dump", + requests: [...asRecordedRequests(requests), ...projectHttp.calls], + exact: true, + }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + }); +}); diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index be949df21d..8ab484068e 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -1,6 +1,14 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../command-internal/command-permissions/model.ts"; +import { dbConfig, dbConfigPoolerFallback } from "../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -80,6 +88,26 @@ export const dbPullCommand = Command.make("pull", config).pipe( "Migration mode compares supabase/migrations with the selected live database (--linked by default), writes the complete difference as migration files, and may record them in that database's migration history. --declarative instead replaces the declarative schema tree and does not create migrations or update migration history.", ), Command.withShortDescription("Pull schema from the remote database"), + withPermissions( + withDbTarget( + compose( + { + operations: [], + noApiEffectFlags: [ + "declarative", + "use-pg-delta", + "diff-engine", + "strict-coverage", + "schema", + ], + }, + dbConfig, + dbConfigPoolerFallback, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "linked" }, + ), + ), Command.withHandler((flags) => dbPull(flags).pipe( withCommandTelemetry({ 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 c7043c7a2c..5499474b82 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -14,10 +14,38 @@ import { } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { vi } from "vitest"; + +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + +const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; +import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { VALID_REF, + VALID_TOKEN, withConfigEnv, withEmptyConfigEnv, withEnvVar, @@ -50,6 +78,7 @@ import { ProjectRefResolver, PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/project-ref.service.ts"; +import { dbConfig } from "../../../command-internal/db-config.layer.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { type DbSession, DbConnection } from "../../../command-internal/db-connection.service.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; @@ -160,6 +189,8 @@ interface SetupOpts { // issues after the migration file is already on disk); the shadow's own internal // migration replay is unaffected. Exercises `DbPullWriteError`'s `writtenSoFar`. readonly historyUpdateFailWith?: string; + // Runs the real resolver and linked-project cache; `workdir` must be a real directory. + readonly realLayers?: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -454,6 +485,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), ).pipe(Layer.provide(BunServices.layer)); + const projectHttp = recordingProjectHttpClient(VALID_REF); const baseLayer = Layer.mergeAll( unusedStackServices, // Listed first so the fake service layers below (`Layer.mergeAll` is last-wins) @@ -461,7 +493,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { BunServices.layer, out.layer, telemetry.layer, - cache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, pgDeltaEngine, edge, docker, @@ -471,10 +508,20 @@ function setup(workdir: string, opts: SetupOpts = {}) { dbConnection, dockerDaemon?.layer ?? shadowSpawner.layer, alwaysReadyHttpClientLayer, - resolver, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver, projectRefResolver, proxy, - mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }), + mockCommandSettings({ + workdir, + projectId: opts.projectId ?? Option.some("test"), + ...(opts.realLayers === true ? { projectHost: "invalid" } : {}), + }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), mockStdin( opts.stdinIsTty ?? false, @@ -515,6 +562,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { edgeCalls, spawnedBeforeEdgeRun, cache, + projectHttp, }; } @@ -2442,3 +2490,233 @@ describe("db remote commit", () => { }).pipe(Effect.provide(s.layer)); }); }); + +describe("db pull and db remote commit permissions through the real resolver and cache", () => { + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const poolerRequest = `GET /v1/projects/${VALID_REF}/config/database/pooler`; + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; + const savedRef = { "supabase/.temp/project-ref": VALID_REF }; + + it.effect("mints a login role and fills the cache for the default linked target", () => { + const s = setup(tmp.current, { realLayers: true }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbPull( + flags({ declarative: Option.some(true), projectRef: Option.some(VALID_REF) }), + ).pipe(Effect.provide(s.layer)); + expect(s.engineCalls.map((call) => call.operation)).toEqual(["export"]); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + expect(s.projectHttp.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + cacheRequest, + ]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db pull", + activeFlags: { declarative: true, "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + // The resolve-phase calls belong to the database config block itself, not to the + // fallback block, which declares the same operations. + assertBlockDrift(dbConfig, { + activeFlags: { linked: true, "project-ref": VALID_REF }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("does not mint a login role when --password is given", () => { + const s = setup(tmp.current, { realLayers: true }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbPull( + flags({ + declarative: Option.some(true), + linked: Option.some(true), + projectRef: Option.some(VALID_REF), + password: Option.some("secret"), + }), + ).pipe(Effect.provide(s.layer)); + expect(asLines(requests)).toEqual([poolerRequest]); + assertPermissionDrift({ + command: "db pull", + activeFlags: { + declarative: true, + linked: true, + "project-ref": VALID_REF, + password: true, + }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + // The resolve-phase calls belong to the database config block itself, not to the + // fallback block, which declares the same operations. + assertBlockDrift(dbConfig, { + activeFlags: { linked: true, "project-ref": VALID_REF, password: true }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("makes no Management API request for --local and caches nothing", () => { + const s = setup(tmp.current, { realLayers: true }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, () => undefined); + yield* dbPull(flags({ declarative: Option.some(true), local: Option.some(true) })).pipe( + Effect.provide(s.layer), + ); + expect(s.engineCalls.map((call) => call.operation)).toEqual(["export"]); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(false); + assertPermissionDrift({ + command: "db pull", + activeFlags: { declarative: true, local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("retries the initial dump through the pooler after a container IPv6 failure", () => { + // No --project-ref: the ref comes from the saved file, so only the fallback block accounts + // for the pooler config request. + const s = setup(tmp.current, { + realLayers: true, + files: savedRef, + remoteVersions: [], + dumpFailFirstWith: "could not translate host name: network is unreachable", + dumpStdout: "", + edgeStdout: "", + yes: true, + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + const error = yield* dbPull(flags()).pipe(Effect.provide(s.layer), Effect.flip); + expect(error.message).toBe("No schema changes found"); + expect(s.dumpCalls).toHaveLength(2); + expect(s.dumpCalls[1]?.env["PGHOST"]).toBe("aws-0-us-east-1.pooler.supabase.com"); + expect(asLines(requests)).toEqual([loginRoleRequest, poolerRequest, loginRoleRequest]); + assertPermissionDrift({ + command: "db pull", + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("db remote commit mints a login role and fills the cache for the linked target", () => { + const s = setup(tmp.current, { + realLayers: true, + migrations: ["20240101000000"], + files: { + ...savedRef, + "supabase/config.toml": "[experimental.pgdelta]\nenabled = true\n", + }, + remoteVersions: ["20240101000000"], + edgeStdout: pgDeltaDiffEnvelope([ + { + name: "schema_changes", + sql: "-- Migration unit 1: schema_changes\n\ncreate table remote ();", + }, + ]), + yes: true, + args: ["db", "remote", "commit"], + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + const fs = yield* FileSystem.FileSystem; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbRemoteCommit(commitFlags()).pipe(Effect.provide(s.layer)); + expect(s.engineCalls.map((call) => call.operation)).toEqual(["diff"]); + expect(asLines(requests)).toEqual([loginRoleRequest]); + expect(s.projectHttp.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + cacheRequest, + ]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db remote commit", + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + // The resolve-phase calls belong to the database config block itself, not to the + // fallback block, which declares the same operations. + assertBlockDrift(dbConfig, { + activeFlags: { linked: true }, + requests: asRecordedRequests(requests), + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("db remote commit does not mint a login role when --password is given", () => { + const s = setup(tmp.current, { + realLayers: true, + migrations: ["20240101000000"], + files: { + ...savedRef, + "supabase/config.toml": "[experimental.pgdelta]\nenabled = true\n", + }, + remoteVersions: ["20240101000000"], + edgeStdout: pgDeltaDiffEnvelope([ + { + name: "schema_changes", + sql: "-- Migration unit 1: schema_changes\n\ncreate table remote ();", + }, + ]), + yes: true, + args: ["db", "remote", "commit", "--linked", "--password", "secret"], + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbRemoteCommit(commitFlags({ linked: true, password: Option.some("secret") })).pipe( + Effect.provide(s.layer), + ); + expect(requests).toEqual([]); + assertPermissionDrift({ + command: "db remote commit", + activeFlags: { linked: true, password: true }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); +}); diff --git a/apps/cli/src/commands/db/remote/commit/commit.command.ts b/apps/cli/src/commands/db/remote/commit/commit.command.ts index 889df8526d..0f9f5f7e68 100644 --- a/apps/cli/src/commands/db/remote/commit/commit.command.ts +++ b/apps/cli/src/commands/db/remote/commit/commit.command.ts @@ -1,6 +1,14 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withDbTarget, +} from "../../../../command-internal/command-permissions/model.ts"; +import { dbConfig, dbConfigPoolerFallback } from "../../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { parseSchemaFlags } from "../../../../command-internal/schema-flags.ts"; @@ -39,6 +47,17 @@ export const dbRemoteCommitCommand = Command.make("commit", config).pipe( "Deprecated: use db pull instead; removed in v3. Commit remote changes as a new migration.", ), Command.withShortDescription("Commit remote changes as a new migration"), + withPermissions( + withDbTarget( + compose( + { operations: [], noApiEffectFlags: ["schema"] }, + dbConfig, + dbConfigPoolerFallback, + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + ), + { default: "linked" }, + ), + ), Command.withHandler((flags) => dbRemoteCommit(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts index 69149d5a18..69eebba87f 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts @@ -2,6 +2,15 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withContext, + type PermissionFragment, +} from "../../../../../command-internal/command-permissions/model.ts"; +import { dbConfigLinked } from "../../../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; import { Output } from "../../../../../shared/output/output.service.ts"; import { aqua } from "../../../../../command-internal/colors.ts"; @@ -63,6 +72,13 @@ const config = { ), } as const; +// Without a target flag, a run whose workdir has migrations can ask where to generate from. +const noTargetFlag = (fragment: PermissionFragment) => + (["db-url", "local", "linked"] as const).reduce( + (current, flag) => withCondition(current, { flag, present: false }), + fragment, + ); + // `--no-cache` is a shared flag on the `declarative` group (read from the parent), // so the handler input merges it in alongside the leaf's own flags. export type DbSchemaDeclarativeGenerateFlags = CliCommand.Command.Config.Infer & { @@ -75,6 +91,34 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi "Exports a live database into the complete declarative schema tree. This replaces declarative files only; it does not create migration files or update migration history. Use --output-dir to stage an export without changing the configured declarative path. In non-interactive use, pass --local, --linked, or --db-url explicitly.", ), Command.withShortDescription("Generate declarative schema from a database"), + withPermissions( + compose( + { + operations: [], + noApiEffectFlags: [ + "overwrite", + "output-dir", + "reset", + "schema", + "no-cache", + "strict-coverage", + ], + }, + withCondition(compose(dbConfigLinked, linkedProjectCache), { flag: "linked" }), + noTargetFlag( + compose( + withContext( + dbConfigLinked, + "migrations exist and the user picks the linked project in the target prompt, which needs an interactive terminal or --yes and a linked project ref", + ), + withContext( + linkedProjectCache, + "migrations exist and SUPABASE_PROJECT_ID or supabase/.temp/project-ref gives a ref, whichever target is picked", + ), + ), + ), + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // `--no-cache` is shared on the parent group; read the resolved value there. 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 c8a74ec2cf..fa89590112 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 @@ -2,7 +2,31 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Schema, Stream } from "effect"; import { StackError, type DatabaseInstance, type Stack } from "@supabase/stack/effect"; +import { vi } from "vitest"; + +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + +const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; +import { withReachableDirectHost } from "../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"; +import { assertPermissionDrift } from "../../../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../../../tests/helpers/permission-real-layers.ts"; import { unusedGateway } from "../../../../../../tests/helpers/unused-stack.ts"; import { @@ -20,6 +44,8 @@ import { mockTty, } from "../../../../../../tests/helpers/mocks.ts"; import { + VALID_REF, + VALID_TOKEN, mockCommandSettings, mockLinkedProjectCacheTracked, mockLocalDockerEngineUnavailableLayer, @@ -76,6 +102,8 @@ interface SetupOpts { exportFails?: boolean; staleLocalImage?: boolean; stackBackend?: boolean; + /** Runs the real resolver and linked-project cache; `workdir` must be a real directory. */ + realLayers?: boolean; } /** What the handler handed the engine for one `exportDeclarativeSchema` call. */ @@ -312,14 +340,26 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.provide(child.layer), Layer.provide(processControl.layer), ); + const projectHttp = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( out.layer, telemetry.layer, - cache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, seam, engine, mockLocalDockerEngineUnavailableLayer, - resolver, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: dbConn, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver, proxy, dbConn, mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }), @@ -359,6 +399,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { engineExportCalls, resolverCalls, proxyCalls, + projectHttp, localPostgresImageChecks, get ensureStartedCalls() { return ensureStartedCalls; @@ -1223,3 +1264,159 @@ describe("db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); }); + +describe("db schema declarative generate permissions through the real resolver and cache", () => { + const tmp = useTempWorkdir(); + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; + const cacheCalls = (calls: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + calls.map(({ method, url }) => `${method} ${url}`); + const saveLinkedRef = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${tmp.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tmp.current}/supabase/.temp/project-ref`, VALID_REF); + }); + + it.effect("mints a login role and fills the cache for --linked", () => { + const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* reachableDirectHost; + yield* saveLinkedRef; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })).pipe( + Effect.provide(s.layer), + ); + expect(s.engineExportCalls).toHaveLength(1); + expect(asLines(requests)).toEqual([loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db schema declarative generate", + activeFlags: { linked: true }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("does not mint a login role for --linked with --password", () => { + const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); + const requests: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + yield* saveLinkedRef; + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbSchemaDeclarativeGenerate( + flags({ linked: Option.some(true), password: Option.some("secret") }), + ).pipe(Effect.provide(s.layer)); + expect(requests).toEqual([]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + assertPermissionDrift({ + command: "db schema declarative generate", + activeFlags: { linked: true, password: true }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("makes no Management API request for --local", () => { + const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); + const requests: Array = []; + return Effect.gen(function* () { + yield* saveLinkedRef; + yield* stubManagementApi(requests, () => undefined); + yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })).pipe( + Effect.provide(s.layer), + ); + expect(s.engineExportCalls).toHaveLength(1); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + assertPermissionDrift({ + command: "db schema declarative generate", + activeFlags: { local: true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("makes no Management API request for --db-url", () => { + const s = setup(tmp.current, { realLayers: true, projectId: Option.some(VALID_REF) }); + const requests: Array = []; + return Effect.gen(function* () { + yield* saveLinkedRef; + yield* stubManagementApi(requests, () => undefined); + yield* dbSchemaDeclarativeGenerate( + flags({ dbUrl: Option.some("postgres://alice:secret@example.com:6543/appdb") }), + ).pipe(Effect.provide(s.layer)); + expect(s.engineExportCalls).toHaveLength(1); + expect(requests).toEqual([]); + expect(s.projectHttp.calls).toEqual([]); + assertPermissionDrift({ + command: "db schema declarative generate", + activeFlags: { "db-url": true }, + requests: [], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect( + "mints a login role and fills the cache when the prompt picks the linked project", + () => { + const s = setup(tmp.current, { + realLayers: true, + stdinIsTty: true, + projectId: Option.some(VALID_REF), + promptSelectResponses: ["linked"], + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + yield* saveLinkedRef; + yield* seedMigration(tmp.current); + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbSchemaDeclarativeGenerate(flags()).pipe(Effect.provide(s.layer)); + expect(s.out.promptSelectCalls[0]?.options.map((o) => o.value)).toEqual([ + "local", + "linked", + "custom", + ]); + // The prompt path's calls carry a prerequisite, so the exact check skips them: assert them + // directly. + expect(asLines(requests)).toEqual([loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + assertPermissionDrift({ + command: "db schema declarative generate", + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); +}); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts index 443b2f8a26..015c886220 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../../../command-internal/command-permissions/model.ts"; +import { dbConfigLinked } from "../../../../../command-internal/db-config.layer.ts"; +import { linkedProjectCache } from "../../../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts"; import { parseSchemaFlags } from "../../../../../command-internal/schema-flags.ts"; import { withCommandTelemetry } from "../../../../../telemetry/command-telemetry.ts"; @@ -56,6 +60,30 @@ export const dbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( "Compares the supabase/migrations baseline with the complete declarative schema tree and writes the difference as migration files. When a legacy export omits known implicit extensions, interactive sync can add declarations and re-plan before writing. Use --no-apply for non-interactive generation without changing the local database; --apply or global --yes applies locally and updates local migration history.", ), Command.withShortDescription("Generate a new migration from declarative schema"), + withPermissions( + compose( + { + operations: [], + noApiEffectFlags: [ + "schema", + "file", + "name", + "apply", + "no-apply", + "no-cache", + "strict-coverage", + ], + }, + withContext( + dbConfigLinked, + "no declarative schema files yet, migrations exist, and the user picks the linked project in the bootstrap target prompt", + ), + withContext( + linkedProjectCache, + "the bootstrap runs with migrations present and SUPABASE_PROJECT_ID or supabase/.temp/project-ref gives a ref, whichever target is picked", + ), + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // `--no-cache` is shared on the parent group; read the resolved value there. 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 2208ddb7cf..135d0acd27 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 @@ -12,8 +12,32 @@ import { Stream, Redacted, } from "effect"; +import { vi } from "vitest"; + +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + +const reachableDirectHost = withReachableDirectHost(fixtureNet); import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; +import { withReachableDirectHost } from "../../../../../../tests/helpers/refuse-invalid-db-hosts.ts"; +import { assertPermissionDrift } from "../../../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../../../tests/helpers/permission-real-layers.ts"; import { unusedGateway } from "../../../../../../tests/helpers/unused-stack.ts"; import { alwaysReadyHttpClientLayer, @@ -30,6 +54,8 @@ import { mockTty, } from "../../../../../../tests/helpers/mocks.ts"; import { + VALID_REF, + VALID_TOKEN, mockCommandSettings, mockLinkedProjectCacheTracked, mockLocalDockerEngineUnavailableLayer, @@ -97,6 +123,8 @@ interface SetupOpts { removals?: PgDeltaRemovalSummary; planErrors?: ReadonlyArray; stackBackend?: boolean; + /** Runs the real resolver and linked-project cache; `workdir` must be a real directory. */ + realLayers?: boolean; } const SYNC_STACK_ID = "e".repeat(64); @@ -355,15 +383,27 @@ function setup(workdir: string, opts: SetupOpts = {}) { }).pipe(Effect.provide(BunServices.layer)), }), ); + const projectHttp = recordingProjectHttpClient(VALID_REF); const layer = Layer.mergeAll( out.layer, telemetry.layer, - cache.layer, + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : cache.layer, seam, engine, mockLocalDockerEngineUnavailableLayer, dbConn, - resolver, + opts.realLayers === true + ? realDbConfigResolver({ + workdir, + dbConnection: dbConn, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }) + : resolver, mockCommandSettings({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), mockStdin(opts.stdinIsTty ?? false), @@ -401,6 +441,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { dbConnectPorts, cache, telemetry, + projectHttp, localPostgresImageChecks, declarativeExportCalls, planDebug, @@ -1543,3 +1584,96 @@ describe("db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); }); + +describe("db schema declarative sync permissions through the real resolver and cache", () => { + const tmp = useTempWorkdir(); + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; + const cacheCalls = (calls: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + calls.map(({ method, url }) => `${method} ${url}`); + const saveLinkedRef = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${tmp.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tmp.current}/supabase/.temp/project-ref`, VALID_REF); + }); + + it.effect( + "the bootstrap mints a login role and fills the cache when it picks the linked project", + () => { + const s = setup(tmp.current, { + realLayers: true, + experimental: true, + stdinIsTty: true, + diffSql: "", + projectId: Option.some(VALID_REF), + promptConfirmResponses: [true], // generate a new one? yes + promptSelectResponses: ["linked"], + }); + const requests: Array = []; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* reachableDirectHost; + yield* saveLinkedRef; + yield* seedMigration(tmp.current); + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + const exit = yield* Effect.exit( + dbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), + ).pipe(Effect.provide(s.layer)); + expect(Exit.isSuccess(exit)).toBe(true); + expect(s.declarativeExportCalls).toHaveLength(1); + // The bootstrap's calls carry a prerequisite, so the exact check skips them: assert them + // directly. + expect(asLines(requests)).toEqual([loginRoleRequest]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + expect(yield* fs.exists(`${tmp.current}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "db schema declarative sync", + activeFlags: { "no-cache": true, "no-apply": true }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); + + it.effect("the bootstrap fills the cache but mints no login role when it picks local", () => { + const s = setup(tmp.current, { + realLayers: true, + experimental: true, + stdinIsTty: true, + diffSql: "", + projectId: Option.some(VALID_REF), + promptConfirmResponses: [true, false], // [generate a new one? yes][reset? no] + promptSelectResponses: ["local"], + }); + const requests: Array = []; + return Effect.gen(function* () { + yield* saveLinkedRef; + yield* seedMigration(tmp.current); + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + const exit = yield* Effect.exit( + dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), + ).pipe(Effect.provide(s.layer)); + expect(Exit.isSuccess(exit)).toBe(true); + expect(s.declarativeExportCalls).toHaveLength(1); + expect(requests).toEqual([]); + expect(cacheCalls(s.projectHttp.calls)).toEqual([cacheRequest]); + assertPermissionDrift({ + command: "db schema declarative sync", + activeFlags: { "no-apply": true }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); +}); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index 349601ee57..841e6d6025 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -34,13 +34,7 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "db branch delete", "db branch list", "db branch switch", - "db diff", - "db dump", - "db pull", "db remote changes", - "db remote commit", - "db schema declarative generate", - "db schema declarative sync", "domains activate", "domains create", diff --git a/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts index 711b719801..a989c82d89 100644 --- a/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts +++ b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts @@ -1,21 +1,47 @@ import type * as NodeNet from "node:net"; +import { Effect } from "effect"; + +/** The switch a test file shares with its `node:net` mock; create it with `vi.hoisted`. */ +export interface DirectHostToggle { + directHostReachable: boolean; +} /** * Wraps `node:net` so a connection to a `db..invalid` fixture host fails at once instead of * waiting on ambient DNS or TCP timing. Use it from a test file's `vi.mock("node:net", ...)` - * factory; other hosts connect normally. + * factory; other hosts connect normally. While the toggle passed in is on, the same hosts report a + * successful connect instead, which fakes the TCP reachability probe only (nothing is dialed). */ -export function refuseInvalidDbHosts(net: typeof NodeNet) { +export function refuseInvalidDbHosts( + net: typeof NodeNet, + toggle: DirectHostToggle = { directHostReachable: false }, +) { return { ...net, - connect: (options: NodeNet.NetConnectOpts) => { - const host = "host" in options ? options.host : undefined; + connect: (connectOptions: NodeNet.NetConnectOpts) => { + const host = "host" in connectOptions ? connectOptions.host : undefined; if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) { - return net.connect(options); + return net.connect(connectOptions); } const socket = new net.Socket(); - queueMicrotask(() => socket.emit("error", new Error("fixture connection refused"))); + queueMicrotask(() => + toggle.directHostReachable + ? socket.emit("connect") + : socket.emit("error", new Error("fixture connection refused")), + ); return socket; }, }; } + +/** Turns the toggle on for the enclosing scope and back off when it closes, even on failure. */ +export const withReachableDirectHost = (toggle: DirectHostToggle) => + Effect.acquireRelease( + Effect.sync(() => { + toggle.directHostReachable = true; + }), + () => + Effect.sync(() => { + toggle.directHostReachable = false; + }), + ); From c9956c374c00d78876f790cc5dcc4382554de1f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:34:57 +0000 Subject: [PATCH 4/4] chore(repo): repair the develop-into-next sync --- apps/cli/src/command-internal/db-config.layer.ts | 16 +++++++++------- .../commands/db/pull/pull.integration.test.ts | 6 +++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 9d375964aa..862cfb694a 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -314,12 +314,14 @@ const describePasswordOrigin = (origin: CliConfigKeyOrigin): string => { }; /** - * An explicitly empty `--password` asks for a temporary login role even when - * `SUPABASE_DB_PASSWORD` or the project `.env` holds one. The flag binding drops empty strings, so - * pass it to {@link resolveLinkedPassword} as the caller's password instead. + * A `--password` the caller bound on {@link DbConfigFlags} is passed to + * {@link resolveLinkedPassword} as the caller's password, so it wins over `SUPABASE_DB_PASSWORD` and + * the project `.env` just like the flag tier does, and no login role is minted for it. An + * explicitly empty `--password` asks for a temporary login role even when the env holds one; the + * flag binding drops empty strings, so this is the only way that request reaches the resolver. */ -const emptyPasswordFlag = (password: Option.Option | undefined): Option.Option => - Option.filter(password ?? Option.none(), (value) => value.length === 0); +const passwordFlag = (password: Option.Option | undefined): Option.Option => + password ?? Option.none(); const loadFailureToDbConfigError = ( error: E, @@ -738,7 +740,7 @@ export const dbConfigResolverLayer = Layer.effect( cliSettings.projectHost, cliSettings.poolerHost, flags.dnsResolver, - emptyPasswordFlag(flags.password), + passwordFlag(flags.password), { adHocProjectRef: flags.adHocProjectRef ?? false, resolveVaultSecrets, @@ -807,7 +809,7 @@ export const dbConfigResolverLayer = Layer.effect( const password = yield* resolveLinkedPassword( ref, cliSettings.workdir, - emptyPasswordFlag(flags.password), + passwordFlag(flags.password), flags.password !== undefined, ); // Container-fallback: fetch the primary pooler config from the Management API when 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 3d48d5428b..400fb7e7d1 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -2581,7 +2581,11 @@ describe("db pull permissions through the real resolver and cache", () => { return Effect.gen(function* () { yield* reachableDirectHost; yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); - const error = yield* dbPull(flags()).pipe(Effect.provide(s.layer), Effect.flip); + // The initial pg_dump seed only runs on the migra engine; pg-delta is the default. + const error = yield* dbPull(flags({ diffEngine: Option.some("migra") })).pipe( + Effect.provide(s.layer), + Effect.flip, + ); expect(error.message).toBe("No schema changes found"); expect(s.dumpCalls).toHaveLength(2); expect(s.dumpCalls[1]?.env["PGHOST"]).toBe("aws-0-us-east-1.pooler.supabase.com");